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
1,707 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//! - Anything else: a static site. Its `build` script runs, and the first
26//! of `dist`, `build`, `out`, `public`, `_site` or `.output/public` that
27//! exists is served, or the repository itself if it has an `index.html`.
28//!
29//! The project's own **Build command** and **Output directory** win over
30//! what is detected.
31//!
32//! Configuration comes from the environment:
33//!
34//! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report.
35//! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out.
36//! - `ROOT_DIR`: where in the repository the project lives; empty for all
37//! of it. Everything below is relative to it.
38//! - `BUILD_COMMAND`, `OUTPUT_DIR`: the project's own choices, if any.
39//! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's
40//! variables and secrets for deploy builds. Both are set for the build;
41//! secrets' values are redacted from its log.
42//!
43//! From a workflow step, `g1t-runner deploy` runs the same build in the
44//! job's own checkout: without `GIT_REMOTE` nothing is cloned, and the
45//! project is the current directory (`ROOT_DIR` below it). The build's id
46//! and token come from `POST /repos/{owner}/{repo}/deployments/builds`,
47//! which the `g1t/deploy` action calls (actions/deploy). Its output goes
48//! to the step's log as well as the deployment's. `g1t-runner detect`
49//! says what the build would find, as `key=value` lines for
50//! `$GITHUB_OUTPUT`, without building anything.
51
52use std::collections::{BTreeMap, BTreeSet};
53use std::path::{Path, PathBuf};
54use std::time::Instant;
55
56use anyhow::{Context, Result, bail};
57use base64::Engine;
58use base64::engine::general_purpose::STANDARD;
59use serde::{Deserialize, Serialize};
60use serde_json::{Value, json};
61use sha2::{Digest, Sha256};
62
63use crate::checks::{redact, run_command};
64use crate::{WORKDIR, auth_option, env, git};
65
66mod frameworks;
67
68use frameworks::{Adapter, Framework};
69
70/// Where `wrangler deploy --dry-run` writes the bundle in a sandbox.
71const BUNDLE_DIR: &str = "/work/g1t-bundle";
72/// Cloudflare's limits on a Worker's static assets.
73const MAX_FILES: usize = 20_000;
74const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;
75/// How much of the build's output is kept for the deployment's log.
76const MAX_LOG_CHARS: usize = 20_000;
77/// Directories a static build usually writes to, in the order they are tried.
78const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"];
79/// Bindings a Workers project may declare that are not provisioned yet.
80const UNSUPPORTED_BINDINGS: [&str; 10] = [
81 "kv_namespaces",
82 "d1_databases",
83 "r2_buckets",
84 "durable_objects",
85 "services",
86 "queues",
87 "vectorize",
88 "hyperdrive",
89 "ai",
90 "workflows",
91];
92
93struct Reporter {
94 base: String,
95 token: String,
96}
97
98impl Reporter {
99 fn send(&self, step: &str, mut body: Value) -> Result<Value> {
100 body["token"] = self.token.clone().into();
101 let response = ureq::post(&format!("{}/{step}", self.base))
102 .send_json(body)
103 .with_context(|| format!("could not report `{step}` to g1t"))?;
104 Ok(response.into_json().unwrap_or(Value::Null))
105 }
106}
107
108/// The build's log, kept to its end; also printed, in a workflow step.
109#[derive(Default)]
110struct Log {
111 text: String,
112 /// Each line to standard output as well, redacted.
113 echo: Option<Vec<String>>,
114}
115
116impl Log {
117 fn line(&mut self, line: &str) {
118 self.text.push_str(line);
119 self.text.push('\n');
120 if let Some(secrets) = &self.echo {
121 println!("{}", redact(line, secrets));
122 }
123 }
124
125 fn tail(&self) -> String {
126 let length = self.text.chars().count();
127 if length <= MAX_LOG_CHARS {
128 return self.text.clone();
129 }
130 let kept: String = self.text.chars().skip(length - MAX_LOG_CHARS).collect();
131 format!("… (earlier output not shown)\n{kept}")
132 }
133}
134
135/// Runs a command in the checkout, logging it; fails if it fails.
136fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> {
137 log.line(&format!("$ {command}"));
138 let result = run_command(command, &project_dir(), secrets);
139 if !result.output_text().is_empty() {
140 log.line(result.output_text());
141 }
142 if !result.passed {
143 bail!("`{command}` failed");
144 }
145 Ok(())
146}
147
148/// A Workers project's settings, from whichever config file it has.
149#[derive(Debug, Default, Deserialize)]
150struct WranglerConfig {
151 main: Option<String>,
152 compatibility_date: Option<String>,
153 #[serde(default)]
154 compatibility_flags: Vec<String>,
155 assets: Option<AssetsConfig>,
156 #[serde(default)]
157 vars: BTreeMap<String, Value>,
158 #[serde(flatten)]
159 rest: BTreeMap<String, Value>,
160}
161
162#[derive(Debug, Default, Deserialize)]
163struct AssetsConfig {
164 directory: Option<String>,
165 binding: Option<String>,
166 html_handling: Option<String>,
167 not_found_handling: Option<String>,
168}
169
170/// JSON with comments and trailing commas, as `wrangler.jsonc` allows.
171fn strip_jsonc(text: &str) -> String {
172 let mut out = String::with_capacity(text.len());
173 let mut chars = text.chars().peekable();
174 let mut in_string = false;
175 while let Some(c) = chars.next() {
176 if in_string {
177 out.push(c);
178 if c == '\\' {
179 if let Some(next) = chars.next() {
180 out.push(next);
181 }
182 } else if c == '"' {
183 in_string = false;
184 }
185 continue;
186 }
187 match (c, chars.peek()) {
188 ('"', _) => {
189 in_string = true;
190 out.push(c);
191 }
192 ('/', Some('/')) => {
193 for c in chars.by_ref() {
194 if c == '\n' {
195 out.push('\n');
196 break;
197 }
198 }
199 }
200 ('/', Some('*')) => {
201 chars.next();
202 let mut last = ' ';
203 for c in chars.by_ref() {
204 if last == '*' && c == '/' {
205 break;
206 }
207 last = c;
208 }
209 }
210 _ => out.push(c),
211 }
212 }
213 // Trailing commas before a closing bracket.
214 let mut cleaned = String::with_capacity(out.len());
215 let chars: Vec<char> = out.chars().collect();
216 let mut in_string = false;
217 let mut i = 0;
218 while i < chars.len() {
219 let c = chars[i];
220 if c == '"' && (i == 0 || chars[i - 1] != '\\') {
221 in_string = !in_string;
222 }
223 if c == ',' && !in_string {
224 let next = chars[i + 1..].iter().find(|c| !c.is_whitespace());
225 if matches!(next, Some('}') | Some(']')) {
226 i += 1;
227 continue;
228 }
229 }
230 cleaned.push(c);
231 i += 1;
232 }
233 cleaned
234}
235
236fn read_wrangler(dir: &Path) -> Result<Option<WranglerConfig>> {
237 for name in ["wrangler.jsonc", "wrangler.json"] {
238 let path = dir.join(name);
239 if path.exists() {
240 let text = std::fs::read_to_string(&path)?;
241 return Ok(Some(
242 serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {name}"))?,
243 ));
244 }
245 }
246 let path = dir.join("wrangler.toml");
247 if path.exists() {
248 let text = std::fs::read_to_string(&path)?;
249 return Ok(Some(toml::from_str(&text).context("could not read wrangler.toml")?));
250 }
251 Ok(None)
252}
253
254/// How to install the project's dependencies, judged by its lockfile.
255fn install_command(dir: &Path) -> Option<&'static str> {
256 if !dir.join("package.json").exists() {
257 return None;
258 }
259 Some(if dir.join("pnpm-lock.yaml").exists() {
260 "corepack enable && pnpm install --frozen-lockfile"
261 } else if dir.join("yarn.lock").exists() {
262 "corepack enable && yarn install"
263 } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
264 "npx --yes bun install"
265 } else if dir.join("package-lock.json").exists() {
266 "npm ci"
267 } else {
268 "npm install"
269 })
270}
271
272fn has_build_script(dir: &Path) -> bool {
273 std::fs::read_to_string(dir.join("package.json"))
274 .ok()
275 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
276 .is_some_and(|package| package["scripts"]["build"].is_string())
277}
278
279/// One file of the site, as Cloudflare's asset upload names it.
280struct Asset {
281 path: String,
282 hash: String,
283 size: u64,
284 file: PathBuf,
285}
286
287fn content_type(path: &str) -> &'static str {
288 let extension = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
289 match extension.as_str() {
290 "html" | "htm" => "text/html",
291 "css" => "text/css",
292 "js" | "mjs" => "application/javascript",
293 "json" | "map" => "application/json",
294 "svg" => "image/svg+xml",
295 "png" => "image/png",
296 "jpg" | "jpeg" => "image/jpeg",
297 "gif" => "image/gif",
298 "webp" => "image/webp",
299 "avif" => "image/avif",
300 "ico" => "image/x-icon",
301 "woff" => "font/woff",
302 "woff2" => "font/woff2",
303 "ttf" => "font/ttf",
304 "txt" => "text/plain",
305 "xml" => "application/xml",
306 "wasm" => "application/wasm",
307 "pdf" => "application/pdf",
308 "mp4" => "video/mp4",
309 "webm" => "video/webm",
310 _ => "application/octet-stream",
311 }
312}
313
314/// What is never served from the top of a site: a Pages-style Worker
315/// (`_worker.js`, its server code, which a Cloudflare adapter writes beside
316/// the site's files), `_routes.json`, and `.assetsignore`, with each name
317/// that `.assetsignore` lists, as wrangler leaves them out.
318fn ignored_at_root(root: &Path) -> BTreeSet<String> {
319 let mut ignored: BTreeSet<String> = ["_worker.js", "_routes.json", ".assetsignore"].map(str::to_owned).into();
320 if let Ok(text) = std::fs::read_to_string(root.join(".assetsignore")) {
321 ignored.extend(
322 text.lines()
323 .map(|line| line.trim().trim_matches('/'))
324 .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.contains(['*', '/', '!']))
325 .map(str::to_owned),
326 );
327 }
328 ignored
329}
330
331/// Every file of the site at `root`, but for what never belongs in one.
332fn collect_site(root: &Path, skip_project: bool) -> Result<Vec<Asset>> {
333 let mut assets = Vec::new();
334 collect(root, root, skip_project, &ignored_at_root(root), &mut assets)?;
335 Ok(assets)
336}
337
338fn collect(root: &Path, dir: &Path, skip_project: bool, ignored: &BTreeSet<String>, out: &mut Vec<Asset>) -> Result<()> {
339 for entry in std::fs::read_dir(dir)? {
340 let entry = entry?;
341 let name = entry.file_name().to_string_lossy().into_owned();
342 let path = entry.path();
343 let kind = entry.file_type()?;
344 if name == ".git" || (skip_project && (name == "node_modules" || name.starts_with(".g1t"))) {
345 continue;
346 }
347 if dir == root && ignored.contains(&name) {
348 continue;
349 }
350 if kind.is_dir() {
351 collect(root, &path, skip_project, ignored, out)?;
352 continue;
353 }
354 if !kind.is_file() || name == "_headers" || name == "_redirects" {
355 continue;
356 }
357 let size = entry.metadata()?.len();
358 let relative = path
359 .strip_prefix(root)?
360 .to_string_lossy()
361 .replace('\\', "/");
362 if size > MAX_FILE_BYTES {
363 bail!("{relative} is larger than Cloudflare's 25 MiB limit for one file");
364 }
365 let bytes = std::fs::read(&path)?;
366 let digest = hex::encode(Sha256::digest(&bytes));
367 out.push(Asset {
368 path: format!("/{relative}"),
369 hash: digest[..32].to_owned(),
370 size,
371 file: path,
372 });
373 if out.len() > MAX_FILES {
374 bail!("the site has more than {MAX_FILES} files, Cloudflare's limit");
375 }
376 }
377 Ok(())
378}
379
380/// The upload, as the API sends it: `snake_case`, or `uploadUrl` from
381/// before its bodies were.
382#[derive(Deserialize)]
383struct UploadSession {
384 jwt: String,
385 #[serde(default)]
386 buckets: Vec<Vec<String>>,
387 #[serde(alias = "uploadUrl")]
388 upload_url: String,
389}
390
391/// Sends one bucket of files with the upload's key. The last bucket's
392/// answer carries the key that completes the upload.
393fn upload_bucket(session: &UploadSession, bucket: &[String], by_hash: &BTreeMap<&str, &Asset>) -> Result<Option<String>> {
394 let boundary = format!("g1t-{}", hex::encode(Sha256::digest(bucket.join(",").as_bytes()))[..24].to_owned());
395 let mut body: Vec<u8> = Vec::new();
396 for hash in bucket {
397 let asset = by_hash
398 .get(hash.as_str())
399 .with_context(|| format!("Cloudflare asked for a file this build does not have ({hash})"))?;
400 let bytes = std::fs::read(&asset.file)?;
401 body.extend_from_slice(
402 format!(
403 "--{boundary}\r\nContent-Disposition: form-data; name=\"{hash}\"; filename=\"{hash}\"\r\nContent-Type: {}\r\n\r\n",
404 content_type(&asset.path)
405 )
406 .as_bytes(),
407 );
408 body.extend_from_slice(STANDARD.encode(bytes).as_bytes());
409 body.extend_from_slice(b"\r\n");
410 }
411 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
412 let response = ureq::post(&session.upload_url)
413 .set("authorization", &format!("Bearer {}", session.jwt))
414 .set("content-type", &format!("multipart/form-data; boundary={boundary}"))
415 .send_bytes(&body);
416 let response = match response {
417 Ok(response) => response,
418 Err(ureq::Error::Status(code, response)) => {
419 bail!("Cloudflare refused the upload ({code}): {}", response.into_string().unwrap_or_default())
420 }
421 Err(error) => bail!("could not upload to Cloudflare: {error}"),
422 };
423 let answer: Value = response.into_json().unwrap_or(Value::Null);
424 Ok(answer["result"]["jwt"].as_str().map(str::to_owned))
425}
426
427#[derive(Serialize)]
428#[serde(rename_all = "camelCase")]
429struct Module {
430 name: String,
431 content_base64: String,
432 content_type: String,
433}
434
435/// The bundle `wrangler deploy --dry-run` wrote, main module first.
436fn bundle_modules(main: &str, bundle: &Path) -> Result<(String, Vec<Module>)> {
437 let stem = Path::new(main)
438 .file_stem()
439 .map(|stem| stem.to_string_lossy().into_owned())
440 .unwrap_or_else(|| "index".to_owned());
441 let mut modules = Vec::new();
442 let mut files = Vec::new();
443 collect_files(bundle, &mut files)?;
444 for file in files {
445 let name = file
446 .strip_prefix(bundle)?
447 .to_string_lossy()
448 .replace('\\', "/");
449 let kind = match name.rsplit('.').next().unwrap_or("") {
450 "js" | "mjs" => "application/javascript+module",
451 "wasm" => "application/wasm",
452 "map" | "md" => continue,
453 _ => "text/plain",
454 };
455 modules.push(Module {
456 content_base64: STANDARD.encode(std::fs::read(&file)?),
457 content_type: kind.to_owned(),
458 name,
459 });
460 }
461 let main_name = modules
462 .iter()
463 .map(|module| module.name.clone())
464 .find(|name| *name == format!("{stem}.js") || *name == format!("{stem}.mjs"))
465 .or_else(|| {
466 modules
467 .iter()
468 .find(|module| module.content_type == "application/javascript+module")
469 .map(|module| module.name.clone())
470 })
471 .context("wrangler wrote no JavaScript module")?;
472 Ok((main_name, modules))
473}
474
475fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
476 for entry in std::fs::read_dir(dir)? {
477 let entry = entry?;
478 if entry.file_type()?.is_dir() {
479 collect_files(&entry.path(), out)?;
480 } else {
481 out.push(entry.path());
482 }
483 }
484 Ok(())
485}
486
487/// What a Workers config asks for that g1t.page does not provision, said
488/// as the deployment's warnings: bindings it runs without, and scheduled
489/// (`triggers.crons`) runs that never fire.
490fn config_warnings(config: &WranglerConfig) -> Vec<String> {
491 // An empty list, as a config a framework's build writes has for each
492 // kind of binding (`"d1_databases": []`), declares nothing.
493 fn declares(value: &Value) -> bool {
494 match value {
495 Value::Null => false,
496 Value::Array(items) => !items.is_empty(),
497 Value::Object(fields) => fields.values().any(declares),
498 _ => true,
499 }
500 }
501 let mut warnings = Vec::new();
502 for binding in UNSUPPORTED_BINDINGS {
503 if config.rest.get(binding).is_some_and(declares) {
504 warnings.push(format!("`{binding}` is not provisioned on g1t.page yet, so the app runs without it."));
505 }
506 }
507 let crons = config
508 .rest
509 .get("triggers")
510 .and_then(|triggers| triggers.get("crons"))
511 .and_then(Value::as_array)
512 .map_or(0, |crons| crons.iter().filter(|cron| cron.as_str().is_some_and(|c| !c.trim().is_empty())).count());
513 if crons > 0 {
514 let schedules = if crons == 1 { "schedule" } else { "schedules" };
515 warnings.push(format!(
516 "`triggers.crons` ({crons} {schedules}) is not set up on g1t.page yet, so the Worker's `scheduled` handler never runs."
517 ));
518 }
519 warnings
520}
521
522/// What was built: the Worker's code and settings, and where its site is.
523struct Built {
524 worker: Value,
525 assets_dir: Option<PathBuf>,
526 warnings: Vec<String>,
527 /// What kind of project it was found to be, for its settings page:
528 /// `workers`, `static` (a build's output) or `html` (the files as they are).
529 detected: &'static str,
530 /// The framework it was found to be built with, if any (`astro`…).
531 framework: Option<&'static str>,
532}
533
534/// What a project without a Workers config is: a site it built, or its own
535/// files served as they are.
536fn static_kind(project: &Path, assets_dir: &Path, built: bool) -> &'static str {
537 if !built && assets_dir == project { "html" } else { "static" }
538}
539
540/// The command that bundles a Workers project, without deploying it,
541/// into `bundle` when given, by the config at `config` when given.
542fn wrangler_build(bundle: Option<&Path>, config: Option<&Path>) -> String {
543 let mut command = "npx --yes wrangler@4 deploy --dry-run".to_owned();
544 if let Some(config) = config {
545 command.push_str(&format!(" --config {}", config.display()));
546 }
547 if let Some(bundle) = bundle {
548 command.push_str(&format!(" --outdir {}", bundle.display()));
549 }
550 command
551}
552
553/// The config a framework's build wrote for wrangler, which wrangler reads
554/// in place of the project's own (`.wrangler/deploy/config.json`, as
555/// `@cloudflare/vite-plugin` writes it), with the folder its paths are
556/// relative to.
557fn redirected_config(dir: &Path) -> Result<Option<(WranglerConfig, PathBuf)>> {
558 let pointer = dir.join(".wrangler/deploy/config.json");
559 let Ok(text) = std::fs::read_to_string(&pointer) else { return Ok(None) };
560 let Some(path) = serde_json::from_str::<Value>(&text).ok().and_then(|v| v["configPath"].as_str().map(str::to_owned)) else {
561 return Ok(None);
562 };
563 let path = dir.join(".wrangler/deploy").join(path);
564 let path = path.canonicalize().unwrap_or(path);
565 let text = std::fs::read_to_string(&path).with_context(|| format!("could not read {}", path.display()))?;
566 let config = serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {}", path.display()))?;
567 Ok(Some((config, path.parent().map_or_else(|| dir.to_path_buf(), Path::to_path_buf))))
568}
569
570/// The entry of the Worker a framework's adapter wrote, once it has built:
571/// the file, or a directory's `index.js`.
572fn adapter_entry(dir: &Path, adapter: &Adapter) -> Option<PathBuf> {
573 let entry = dir.join(adapter.entry);
574 if entry.is_file() {
575 return Some(entry);
576 }
577 ["index.js", "index.mjs"].iter().map(|name| entry.join(name)).find(|index| index.is_file())
578}
579
580/// The first of the usual output directories a static build wrote (or the
581/// project's own `index.html`), relative to the project: `.` for itself.
582fn find_output(dir: &Path) -> Option<String> {
583 OUTPUT_DIRS
584 .iter()
585 .find(|name| {
586 let path = dir.join(name);
587 path.join("index.html").exists() || (path.is_dir() && **name != "public")
588 })
589 .map(|name| (*name).to_owned())
590 .or_else(|| dir.join("index.html").exists().then(|| ".".to_owned()))
591}
592
593/// What a build of the project at `dir` does, found before anything runs:
594/// what it is, how its dependencies are installed, what builds it, and
595/// where what it serves is. For a site with a build of its own, the output
596/// directory may only exist once it has run; then `output_dir` is `None`
597/// unless one was chosen or the framework says where it writes.
598#[derive(Debug, PartialEq, Eq)]
599pub(crate) struct Detected {
600 /// `workers`, `static`, `html`, or `none` when there is nothing to serve.
601 pub kind: &'static str,
602 /// The framework it is built with, if g1t knows it.
603 pub framework: Option<Framework>,
604 pub install: Option<String>,
605 pub build: Option<String>,
606 pub output_dir: Option<String>,
607 /// Why it cannot be served as it is set up, and what to change: the
608 /// kind is then `none`.
609 pub problem: Option<String>,
610 /// What runs before wrangler bundles a Workers project; for anything
611 /// else, `build`.
612 pre_build: Option<String>,
613}
614
615pub(crate) fn detect(dir: &Path, custom_build: Option<&str>, chosen_output: Option<&str>) -> Result<Detected> {
616 let custom_build = custom_build.map(str::trim).filter(|c| !c.is_empty());
617 let chosen_output = chosen_output.map(|d| d.trim().trim_matches('/')).filter(|d| !d.is_empty());
618 let config = read_wrangler(dir)?;
619 let framework = frameworks::detect(dir, config.is_some());
620 let installs: Vec<&str> = [install_command(dir), framework.as_ref().and_then(|f| f.install)].into_iter().flatten().collect();
621 let install = (!installs.is_empty()).then(|| installs.join(" && "));
622 let framework_build = framework.as_ref().and_then(|f| f.build.clone());
623 if let Some(config) = config {
624 // A framework built for Workers builds before wrangler bundles what
625 // it wrote; any other project's config builds it, as it always has.
626 let pre_build = custom_build
627 .map(str::to_owned)
628 .or_else(|| framework.as_ref().filter(|f| f.workers).and(framework_build));
629 let mut steps: Vec<String> = pre_build.clone().into_iter().collect();
630 if config.main.is_some() {
631 steps.push(wrangler_build(None, None));
632 }
633 return Ok(Detected {
634 kind: "workers",
635 framework,
636 install,
637 build: (!steps.is_empty()).then(|| steps.join(" && ")),
638 output_dir: config.assets.and_then(|assets| assets.directory),
639 problem: None,
640 pre_build,
641 });
642 }
643 let build = custom_build
644 .map(str::to_owned)
645 .or(framework_build)
646 .or_else(|| has_build_script(dir).then(|| "npm run build".to_owned()));
647 // An adapter's Worker, unless the project chose a folder to serve.
648 if chosen_output.is_none() && framework.as_ref().is_some_and(|f| f.adapter.is_some()) {
649 // Where its files are is known once it has built: the adapter's
650 // own folder, or where the config it writes says.
651 return Ok(Detected {
652 kind: "workers",
653 output_dir: None,
654 framework,
655 install,
656 pre_build: build.clone(),
657 build,
658 problem: None,
659 });
660 }
661 // A command or a folder of the project's own says it knows better.
662 let problem = framework
663 .as_ref()
664 .and_then(|f| f.problem.clone())
665 .filter(|_| custom_build.is_none() && chosen_output.is_none());
666 if problem.is_some() {
667 return Ok(Detected { kind: "none", framework, install, build: None, output_dir: None, problem, pre_build: None });
668 }
669 let output_dir = chosen_output
670 .map(str::to_owned)
671 .or_else(|| framework.as_ref().and_then(|f| f.output.clone()))
672 .or_else(|| find_output(dir));
673 // `.` is the project itself: its own files, served as they are.
674 let kind = match (&build, output_dir.as_deref()) {
675 (None, Some(".")) => "html",
676 (None, None) => "none",
677 _ => "static",
678 };
679 Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None })
680}
681
682/// The compatibility date a Worker gets when its config names none.
683const COMPATIBILITY_DATE: &str = "2026-09-26";
684
685/// A Workers project bundled by wrangler: `config` with its paths relative
686/// to `base`, read by wrangler from `config_file` when given (else the
687/// project's own, or the one its build wrote).
688fn build_worker(
689 log: &mut Log,
690 secrets: &[String],
691 config: WranglerConfig,
692 base: &Path,
693 config_file: Option<&Path>,
694) -> Result<(Value, Option<PathBuf>, Vec<String>)> {
695 let bundle = bundle_dir();
696 let warnings = config_warnings(&config);
697 let mut worker = json!({
698 "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| COMPATIBILITY_DATE.to_owned()),
699 "compatibilityFlags": config.compatibility_flags,
700 "vars": config.vars,
701 });
702 if let Some(main) = &config.main {
703 // `--dry-run` runs the config's own build and bundles it, without
704 // deploying anywhere.
705 let _ = std::fs::remove_dir_all(&bundle);
706 step(log, &wrangler_build(Some(&bundle), config_file), secrets)?;
707 let (main_module, modules) = bundle_modules(main, &bundle)?;
708 worker["mainModule"] = main_module.into();
709 worker["modules"] = serde_json::to_value(modules)?;
710 }
711 let assets = config.assets.unwrap_or_default();
712 let assets_dir = assets.directory.as_ref().map(|directory| base.join(directory));
713 worker["assetsBinding"] = assets.binding.into();
714 worker["htmlHandling"] = assets.html_handling.into();
715 worker["notFoundHandling"] = assets.not_found_handling.into();
716 Ok((worker, assets_dir, warnings))
717}
718
719/// Where the config for an adapter's Worker is written: beside the
720/// bundle, never in the checkout.
721fn adapter_config_path() -> PathBuf {
722 let bundle = bundle_dir();
723 bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy()))
724}
725
726fn build(log: &mut Log, secrets: &[String]) -> Result<Built> {
727 let project = project_dir();
728 let dir = project.as_path();
729 let custom_build = std::env::var("BUILD_COMMAND").ok();
730 let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty());
731 let plan = detect(dir, custom_build.as_deref(), chosen.as_deref())?;
732 let framework_id = plan.framework.as_ref().map(|f| f.id);
733 if let Some(framework) = &plan.framework {
734 log.line(&format!("Detected {}: framework={}.", framework.name, framework.id));
735 }
736 // Said before anything is installed: it would fail after, less clearly.
737 if let Some(problem) = &plan.problem {
738 bail!("{problem}");
739 }
740 if let Some(install) = &plan.install {
741 step(log, install, secrets)?;
742 }
743 if let Some(command) = &plan.pre_build {
744 step(log, command, secrets)?;
745 }
746 let own_config = read_wrangler(dir)?;
747 // A framework built for Workers may write the config wrangler reads
748 // (`@cloudflare/vite-plugin` does, and Astro's adapter with it), with
749 // or without one of the project's own.
750 let written = if own_config.is_some() || plan.framework.as_ref().is_some_and(|f| f.workers) {
751 redirected_config(dir)?
752 } else {
753 None
754 };
755 if let Some((config, base)) = written {
756 log.line(&format!("Using the Workers config the build wrote, in {}.", base.display()));
757 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, &base, None)?;
758 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id });
759 }
760 if let Some(config) = own_config {
761 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, dir, None)?;
762 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id });
763 }
764 let adapter = plan.framework.as_ref().and_then(|f| f.adapter.as_ref()).filter(|_| chosen.is_none());
765 if let Some(adapter) = adapter
766 && let Some(entry) = adapter_entry(dir, adapter)
767 {
768 // The adapter's Worker, bundled as a Workers project's is. Its
769 // static files are uploaded by g1t, so wrangler is given none.
770 let file = adapter_config_path();
771 let flags = vec!["nodejs_compat".to_owned()];
772 std::fs::write(
773 &file,
774 serde_json::to_string_pretty(&json!({
775 "name": "g1t-app",
776 "main": entry,
777 "compatibility_date": COMPATIBILITY_DATE,
778 "compatibility_flags": flags,
779 }))?,
780 )?;
781 log.line(&format!("Bundling the Worker the adapter wrote, {}.", adapter.entry));
782 let config = WranglerConfig {
783 main: Some(entry.to_string_lossy().into_owned()),
784 compatibility_flags: flags,
785 assets: Some(AssetsConfig {
786 directory: Some(adapter.assets.to_owned()),
787 binding: Some("ASSETS".to_owned()),
788 ..AssetsConfig::default()
789 }),
790 ..WranglerConfig::default()
791 };
792 let built = build_worker(log, secrets, config, dir, Some(&file));
793 let _ = std::fs::remove_file(&file);
794 let (worker, assets_dir, warnings) = built?;
795 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id });
796 }
797 if let (Some(adapter), Some(framework)) = (adapter, &plan.framework)
798 && framework.output.is_none()
799 {
800 bail!("{}'s build did not write its Worker, `{}`", framework.name, adapter.entry);
801 }
802 let assets_dir = match &chosen {
803 Some(chosen) => {
804 let path = dir.join(chosen.trim().trim_matches('/'));
805 if !path.is_dir() {
806 bail!("the output directory `{chosen}` does not exist after the build");
807 }
808 path
809 }
810 None => {
811 let expected = plan.framework.as_ref().and_then(|f| f.output.as_deref());
812 match expected.filter(|output| dir.join(output).is_dir()) {
813 Some(output) => dir.join(output),
814 None => {
815 let found = find_output(dir).with_context(|| match (&plan.framework, expected) {
816 (Some(framework), Some(expected)) => format!(
817 "found nothing to serve: {} writes its site to `{expected}`, which the build did not write. \
818 Set **Output directory** if it writes somewhere else.",
819 framework.name
820 ),
821 (Some(framework), None) if framework.problem.is_some() => {
822 format!("found nothing to serve. {}", framework.problem.as_deref().unwrap_or_default())
823 }
824 _ => "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public".to_owned(),
825 })?;
826 if let Some(expected) = expected {
827 log.line(&format!("`{expected}` was not written; serving `{found}`."));
828 }
829 if found == "." { dir.to_path_buf() } else { dir.join(found) }
830 }
831 }
832 }
833 };
834 let spa = !assets_dir.join("404.html").exists();
835 Ok(Built {
836 worker: json!({
837 "compatibilityDate": COMPATIBILITY_DATE,
838 "compatibilityFlags": [],
839 "vars": {},
840 "notFoundHandling": if spa { "single-page-application" } else { "404-page" },
841 }),
842 detected: static_kind(dir, &assets_dir, plan.build.is_some()),
843 assets_dir: Some(assets_dir),
844 warnings: Vec::new(),
845 framework: framework_id,
846 })
847}
848
849/// Whether the build checks out its own commit (a sandbox, given
850/// `GIT_REMOTE`) rather than building the checkout it runs in.
851fn clones() -> bool {
852 std::env::var("GIT_REMOTE").is_ok_and(|remote| !remote.trim().is_empty())
853}
854
855/// The checkout the build runs in: the sandbox's clone, or, from a
856/// workflow step, the current directory.
857fn checkout_dir() -> PathBuf {
858 if clones() {
859 PathBuf::from(WORKDIR)
860 } else {
861 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
862 }
863}
864
865/// Where `wrangler deploy --dry-run` writes the bundle: in a workflow
866/// step, a directory of the job's temporary space, never the checkout.
867fn bundle_dir() -> PathBuf {
868 if clones() {
869 return PathBuf::from(BUNDLE_DIR);
870 }
871 let base = std::env::var_os("RUNNER_TEMP").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
872 base.join(format!("g1t-bundle-{}", std::process::id()))
873}
874
875/// Where the project lives in the checkout `base`: its root directory,
876/// never above the checkout.
877fn project_dir_in(base: &Path, root: &str) -> PathBuf {
878 let root = root.trim().trim_matches('/');
879 if root.is_empty() || root == "." || root.split('/').any(|part| part == "..") {
880 base.to_path_buf()
881 } else {
882 base.join(root)
883 }
884}
885
886/// Where the project lives: `ROOT_DIR` in the checkout.
887fn project_dir() -> PathBuf {
888 project_dir_in(&checkout_dir(), &std::env::var("ROOT_DIR").unwrap_or_default())
889}
890
891fn check_out(secrets: &[String]) -> Result<()> {
892 let remote = env("GIT_REMOTE")?;
893 let commit = env("GIT_COMMIT")?;
894 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
895 std::fs::create_dir_all("/work")?;
896 let workdir = Path::new(WORKDIR);
897 // The clone is shallow and of the default branch; a preview's commit is
898 // often on another one, so it is fetched before checking out.
899 let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR)
900 .and_then(|_| git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"]))
901 .and_then(|branch| crate::clone::ensure(workdir, &auth, "origin", &branch, &commit))
902 .and_then(|_| git(workdir, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit]));
903 if let Err(error) = cloned {
904 bail!("{}", redact(&format!("{error:#}"), secrets));
905 }
906 Ok(())
907}
908
909fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
910 if clones() {
911 check_out(secrets).context("the commit could not be checked out")?;
912 } else {
913 let dir = project_dir();
914 if !dir.is_dir() {
915 bail!("the project's directory {} does not exist in this checkout", dir.display());
916 }
917 log.line(&format!("Building {} as it is checked out.", dir.display()));
918 }
919 // The repository's variables and secrets for deploy builds.
920 for source in ["BUILD_ENV", "BUILD_SECRETS"] {
921 if let Ok(vars) = std::env::var(source)
922 && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
923 {
924 for (name, value) in vars {
925 if let Some(value) = value.as_str() {
926 // SAFETY: single-threaded; set before any command runs.
927 unsafe { std::env::set_var(name, value) };
928 }
929 }
930 }
931 }
932 let built = build(log, secrets)?;
933 let mut finish = json!({
934 "worker": built.worker,
935 "warnings": built.warnings,
936 "detected": built.detected,
937 // Not stored by the deployments service yet, which ignores fields
938 // it does not know; the build's log names it too.
939 "framework": built.framework,
940 });
941 for warning in &built.warnings {
942 log.line(&format!("Warning: {warning}"));
943 }
944 if let Some(dir) = &built.assets_dir {
945 let skip_project = *dir == project_dir();
946 let assets = collect_site(dir, skip_project)?;
947 if assets.is_empty() {
948 bail!("the site to serve is empty");
949 }
950 log.line(&format!("Uploading {} files.", assets.len()));
951 for special in ["_headers", "_redirects"] {
952 if let Ok(text) = std::fs::read_to_string(dir.join(special)) {
953 finish["worker"][special] = text.into();
954 }
955 }
956 let manifest: BTreeMap<&str, Value> = assets
957 .iter()
958 .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size })))
959 .collect();
960 let answer = reporter.send("session", json!({ "manifest": manifest }))?;
961 if answer["ok"] == false {
962 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload"));
963 }
964 let session: UploadSession =
965 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?;
966 let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect();
967 let mut completion = session.jwt.clone();
968 for bucket in &session.buckets {
969 if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? {
970 completion = jwt;
971 }
972 }
973 finish["completionJwt"] = completion.into();
974 }
975 Ok(finish)
976}
977
978/// A sandbox's build (`MODE=deploy`).
979pub fn main() -> i32 {
980 run(false)
981}
982
983/// `g1t-runner deploy`, from a workflow step: the build in the job's own
984/// checkout, its log in the step's as well. Exits 1 when the build fails,
985/// after reporting it, so the step fails too.
986pub fn cli(args: &[String]) -> i32 {
987 if args.iter().any(|arg| arg == "--help" || arg == "-h") {
988 println!("{CLI_HELP}");
989 return 0;
990 }
991 run(true)
992}
993
994const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page.
995
996Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds),
997G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR,
998BUILD_ENV and BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all.
999
1000g1t-runner detect prints what the build would find, as key=value lines.";
1001
1002fn run(step_log: bool) -> i32 {
1003 let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
1004 (Ok(api), Ok(id), Ok(token)) => Reporter {
1005 base: format!("{}/deployments/jobs/{id}", api.trim_end_matches('/')),
1006 token,
1007 },
1008 _ => {
1009 eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set");
1010 return 2;
1011 }
1012 };
1013 let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
1014 .iter()
1015 .filter_map(|name| std::env::var(name).ok())
1016 .filter(|secret| !secret.is_empty())
1017 .collect();
1018 // The repository's build secrets never appear in the log.
1019 if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) {
1020 secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned));
1021 }
1022 if let Err(error) = reporter.send("started", json!({})) {
1023 eprintln!("g1t-runner: {error:#}");
1024 return 1;
1025 }
1026 let started = Instant::now();
1027 let mut log = Log { echo: step_log.then(|| secrets.clone()), ..Log::default() };
1028 let outcome = deploy(&reporter, &mut log, &secrets);
1029 let seconds = started.elapsed().as_secs();
1030 if step_log {
1031 let _ = std::fs::remove_dir_all(bundle_dir());
1032 let _ = std::fs::remove_file(adapter_config_path());
1033 }
1034 let (sent, failed) = match outcome {
1035 Ok(mut finish) => {
1036 finish["log"] = redact(&log.tail(), &secrets).into();
1037 finish["buildSeconds"] = seconds.into();
1038 (reporter.send("finish", finish), false)
1039 }
1040 Err(error) => {
1041 let message = redact(&format!("{error:#}"), &secrets);
1042 log.line(&format!("The build failed: {message}"));
1043 let sent = reporter.send(
1044 "fail",
1045 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
1046 );
1047 (sent, true)
1048 }
1049 };
1050 match sent {
1051 // A sandbox's build has said how it ended; a step fails with its build.
1052 Ok(answer) if step_log && (failed || answer["ok"] == false || answer["value"] == false) => {
1053 if !failed {
1054 eprintln!("g1t-runner: g1t did not take the build; its deployment says why");
1055 }
1056 1
1057 }
1058 Ok(_) => 0,
1059 Err(error) => {
1060 eprintln!("g1t-runner: {error:#}");
1061 1
1062 }
1063 }
1064}
1065
1066/// `g1t-runner detect`: what a build of the project would find, as
1067/// `key=value` lines for `$GITHUB_OUTPUT`: `kind`, `install`, `build`,
1068/// `output-dir`, and `framework` when one is found. Reads `ROOT_DIR`,
1069/// `BUILD_COMMAND` and `OUTPUT_DIR` as the build does; `--root-dir DIR` is
1070/// the same as `ROOT_DIR`. A framework set up to need a server is `none`,
1071/// with what to change on standard error.
1072pub fn detect_cli(args: &[String]) -> i32 {
1073 let mut root = std::env::var("ROOT_DIR").unwrap_or_default();
1074 let mut rest = args.iter();
1075 while let Some(arg) = rest.next() {
1076 match arg.as_str() {
1077 "--root-dir" => root = rest.next().cloned().unwrap_or_default(),
1078 "--help" | "-h" => {
1079 println!(
1080 "g1t-runner detect [--root-dir DIR]: prints kind, install, build, output-dir and framework (when one is found) as key=value lines."
1081 );
1082 return 0;
1083 }
1084 other => {
1085 eprintln!("g1t-runner detect: unknown argument {other}");
1086 return 2;
1087 }
1088 }
1089 }
1090 let dir = project_dir_in(&checkout_dir(), &root);
1091 let custom = std::env::var("BUILD_COMMAND").ok();
1092 let output = std::env::var("OUTPUT_DIR").ok();
1093 match detect(&dir, custom.as_deref(), output.as_deref()) {
1094 Ok(found) => {
1095 print!("{}", output_lines(&found));
1096 if let Some(problem) = &found.problem {
1097 eprintln!("g1t-runner detect: {problem}");
1098 }
1099 0
1100 }
1101 Err(error) => {
1102 eprintln!("g1t-runner detect: {error:#}");
1103 1
1104 }
1105 }
1106}
1107
1108/// `Detected` as `$GITHUB_OUTPUT` lines; a value on one line, empty when
1109/// there is none. `framework` is left out when none was found, which a
1110/// step's outputs read as empty all the same.
1111fn output_lines(found: &Detected) -> String {
1112 let one_line = |value: Option<&str>| value.unwrap_or_default().replace(['\r', '\n'], " ");
1113 let mut lines = format!(
1114 "kind={}\ninstall={}\nbuild={}\noutput-dir={}\n",
1115 found.kind,
1116 one_line(found.install.as_deref()),
1117 one_line(found.build.as_deref()),
1118 one_line(found.output_dir.as_deref()),
1119 );
1120 if let Some(framework) = &found.framework {
1121 lines.push_str(&format!("framework={}\n", framework.id));
1122 }
1123 lines
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128 use super::*;
1129
1130 #[test]
1131 fn the_upload_session_is_read_in_either_spelling() {
1132 for answer in [
1133 json!({ "jwt": "j", "buckets": [["a"]], "upload_url": "https://u" }),
1134 json!({ "jwt": "j", "uploadUrl": "https://u" }),
1135 ] {
1136 let session: UploadSession = serde_json::from_value(answer).unwrap();
1137 assert_eq!(session.upload_url, "https://u");
1138 }
1139 }
1140
1141 #[test]
1142 fn jsonc_comments_and_trailing_commas_are_dropped() {
1143 let text = r#"{
1144 // a comment
1145 "main": "src/index.ts", /* another */
1146 "vars": { "URL": "https://x.dev//not-a-comment", },
1147 }"#;
1148 let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap();
1149 assert_eq!(config.main.as_deref(), Some("src/index.ts"));
1150 assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment");
1151 }
1152
1153 #[test]
1154 fn unsupported_bindings_are_noticed() {
1155 let config: WranglerConfig =
1156 serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap();
1157 assert!(config.rest.contains_key("d1_databases"));
1158 }
1159
1160 #[test]
1161 fn crons_g1t_does_not_run_are_warned_about() {
1162 let config: WranglerConfig =
1163 serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": ["*/5 * * * *", "0 3 * * *"] }, "kv_namespaces": [{ "binding": "KV" }] }"#)
1164 .unwrap();
1165 let warnings = config_warnings(&config);
1166 assert_eq!(warnings.len(), 2);
1167 assert!(warnings.iter().any(|w| w.contains("kv_namespaces")));
1168 assert!(warnings.iter().any(|w| w.contains("triggers.crons") && w.contains("2 schedules")));
1169 let none: WranglerConfig = serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": [] } }"#).unwrap();
1170 assert!(config_warnings(&none).is_empty());
1171 let toml: WranglerConfig = toml::from_str("main = \"a.js\"
1172[triggers]
1173crons = [\"0 * * * *\"]
1174").unwrap();
1175 assert!(config_warnings(&toml)[0].contains("1 schedule)"));
1176 // A config a build wrote lists every kind of binding, mostly empty.
1177 let written: WranglerConfig = serde_json::from_str(
1178 r#"{ "main": "entry.mjs", "d1_databases": [], "durable_objects": { "bindings": [] }, "triggers": {}, "kv_namespaces": [{ "binding": "SESSION" }] }"#,
1179 )
1180 .unwrap();
1181 assert_eq!(config_warnings(&written).len(), 1);
1182 }
1183
1184 #[test]
1185 fn a_site_is_plain_html_only_when_its_own_files_are_served_unbuilt() {
1186 let project = Path::new("/w/site");
1187 assert_eq!(static_kind(project, project, false), "html");
1188 assert_eq!(static_kind(project, project, true), "static");
1189 assert_eq!(static_kind(project, &project.join("dist"), false), "static");
1190 }
1191
1192 #[test]
1193 fn files_are_typed_by_extension() {
1194 assert_eq!(content_type("/index.HTML"), "text/html");
1195 assert_eq!(content_type("/a/b.woff2"), "font/woff2");
1196 assert_eq!(content_type("/LICENSE"), "application/octet-stream");
1197 }
1198
1199 /// A fresh directory with `files` (path, contents) in it.
1200 fn project(name: &str, files: &[(&str, &str)]) -> PathBuf {
1201 let dir = std::env::temp_dir().join(format!("g1t-detect-{name}-{}", std::process::id()));
1202 let _ = std::fs::remove_dir_all(&dir);
1203 for (path, text) in files {
1204 let path = dir.join(path);
1205 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1206 std::fs::write(path, text).unwrap();
1207 }
1208 std::fs::create_dir_all(&dir).unwrap();
1209 dir
1210 }
1211
1212 #[test]
1213 fn detect_finds_a_workers_project_and_how_it_builds() {
1214 let dir = project(
1215 "workers",
1216 &[
1217 ("wrangler.jsonc", r#"{ "main": "src/index.ts", "assets": { "directory": "./public" }, }"#),
1218 ("package.json", "{}"),
1219 ("pnpm-lock.yaml", ""),
1220 ],
1221 );
1222 let found = detect(&dir, None, None).unwrap();
1223 assert_eq!(found.kind, "workers");
1224 assert_eq!(found.install.as_deref(), Some("corepack enable && pnpm install --frozen-lockfile"));
1225 assert_eq!(found.framework, None);
1226 assert_eq!(found.build.as_deref(), Some("npx --yes wrangler@4 deploy --dry-run"));
1227 assert_eq!(found.output_dir.as_deref(), Some("./public"));
1228 // The project's own build command runs first.
1229 let custom = detect(&dir, Some("npm run codegen"), None).unwrap();
1230 assert_eq!(custom.build.as_deref(), Some("npm run codegen && npx --yes wrangler@4 deploy --dry-run"));
1231 }
1232
1233 #[test]
1234 fn detect_finds_a_static_build_and_a_plain_site() {
1235 let built = project("static", &[("package.json", r#"{ "scripts": { "build": "vite build" } }"#), ("package-lock.json", "{}")]);
1236 let found = detect(&built, None, None).unwrap();
1237 assert_eq!(found.kind, "static");
1238 assert_eq!(found.install.as_deref(), Some("npm ci"));
1239 assert_eq!(found.build.as_deref(), Some("npm run build"));
1240 assert_eq!(found.output_dir, None, "its output is found once it has built");
1241 assert_eq!(found.framework, None, "a build script alone names no framework");
1242 std::fs::create_dir_all(built.join("dist")).unwrap();
1243 std::fs::write(built.join("dist/index.html"), "<h1>hi</h1>").unwrap();
1244 assert_eq!(detect(&built, None, None).unwrap().output_dir.as_deref(), Some("dist"));
1245 assert_eq!(detect(&built, None, Some("/site/")).unwrap().output_dir.as_deref(), Some("site"));
1246
1247 let html = project("html", &[("index.html", "<h1>hi</h1>")]);
1248 let found = detect(&html, None, None).unwrap();
1249 assert_eq!((found.kind, found.install, found.build, found.output_dir.as_deref()), ("html", None, None, Some(".")));
1250 // A `public` folder alone, without an index.html, is not a site.
1251 let empty = project("empty", &[("public/robots.txt", ""), ("README.md", "")]);
1252 assert_eq!(detect(&empty, None, None).unwrap().kind, "none");
1253 }
1254
1255 #[test]
1256 fn detect_prints_lines_for_github_output() {
1257 let mut found = Detected {
1258 kind: "static",
1259 framework: None,
1260 install: Some("npm ci".into()),
1261 build: Some("a\nb".into()),
1262 output_dir: None,
1263 problem: None,
1264 pre_build: None,
1265 };
1266 assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n");
1267 let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]);
1268 found.framework = frameworks::detect(&vite, false);
1269 assert!(output_lines(&found).ends_with("output-dir=\nframework=vite\n"));
1270 }
1271
1272 /// One project of the table below: its files, the project's own
1273 /// settings, and what detect should say.
1274 struct Case {
1275 name: &'static str,
1276 files: &'static [(&'static str, &'static str)],
1277 custom_build: Option<&'static str>,
1278 chosen_output: Option<&'static str>,
1279 kind: &'static str,
1280 framework: Option<&'static str>,
1281 build: Option<&'static str>,
1282 output_dir: Option<&'static str>,
1283 /// A phrase the problem says, if there is one.
1284 problem: Option<&'static str>,
1285 }
1286
1287 const CASE: Case = Case {
1288 name: "",
1289 files: &[],
1290 custom_build: None,
1291 chosen_output: None,
1292 kind: "static",
1293 framework: None,
1294 build: Some("npm run build"),
1295 output_dir: None,
1296 problem: None,
1297 };
1298
1299 #[test]
1300 fn detect_knows_each_framework() {
1301 let cases = [
1302 Case {
1303 name: "vite",
1304 files: &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)],
1305 framework: Some("vite"),
1306 build: Some("npx vite build"),
1307 output_dir: Some("dist"),
1308 ..CASE
1309 },
1310 Case {
1311 name: "vite-react",
1312 files: &[
1313 ("package.json", r#"{ "scripts": { "build": "tsc && vite build" }, "dependencies": { "react": "19" }, "devDependencies": { "vite": "7", "@vitejs/plugin-react": "5" } }"#),
1314 ("vite.config.ts", "export default defineConfig({ plugins: [react()], build: { outDir: 'site' } })"),
1315 ],
1316 framework: Some("vite-react"),
1317 output_dir: Some("site"),
1318 ..CASE
1319 },
1320 Case {
1321 name: "vite-vue",
1322 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7", "@vitejs/plugin-vue": "6" } }"#)],
1323 framework: Some("vite-vue"),
1324 output_dir: Some("dist"),
1325 ..CASE
1326 },
1327 Case {
1328 name: "vite-svelte",
1329 files: &[("package.json", r#"{ "devDependencies": { "vite": "7", "@sveltejs/vite-plugin-svelte": "6" } }"#)],
1330 framework: Some("vite-svelte"),
1331 build: Some("npx vite build"),
1332 output_dir: Some("dist"),
1333 ..CASE
1334 },
1335 Case {
1336 name: "vite-solid",
1337 files: &[("package.json", r#"{ "devDependencies": { "vite": "7", "vite-plugin-solid": "2" } }"#)],
1338 framework: Some("vite-solid"),
1339 build: Some("npx vite build"),
1340 output_dir: Some("dist"),
1341 ..CASE
1342 },
1343 Case {
1344 name: "vite-cloudflare",
1345 files: &[
1346 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7", "@cloudflare/vite-plugin": "1" } }"#),
1347 ("wrangler.jsonc", r#"{ "main": "worker/index.ts" }"#),
1348 ],
1349 kind: "workers",
1350 framework: Some("vite"),
1351 build: Some("npm run build && npx --yes wrangler@4 deploy --dry-run"),
1352 ..CASE
1353 },
1354 Case {
1355 name: "astro",
1356 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5" } }"#), ("astro.config.mjs", "export default defineConfig({ site: 'https://x.dev' })")],
1357 framework: Some("astro"),
1358 output_dir: Some("dist"),
1359 ..CASE
1360 },
1361 Case {
1362 name: "astro-server",
1363 files: &[("package.json", r#"{ "dependencies": { "astro": "5", "@astrojs/node": "9" } }"#), ("astro.config.mjs", "export default { output: \"server\" }")],
1364 kind: "none",
1365 framework: Some("astro"),
1366 build: None,
1367 problem: Some("`output: 'static'`"),
1368 ..CASE
1369 },
1370 Case {
1371 name: "astro-cloudflare",
1372 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5", "@astrojs/cloudflare": "12" } }"#)],
1373 kind: "workers",
1374 framework: Some("astro"),
1375 ..CASE
1376 },
1377 Case {
1378 name: "astro-cloudflare-folder",
1379 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5", "@astrojs/cloudflare": "12" } }"#)],
1380 chosen_output: Some("dist"),
1381 framework: Some("astro"),
1382 output_dir: Some("dist"),
1383 ..CASE
1384 },
1385 Case {
1386 name: "next-export",
1387 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.ts", "const config: NextConfig = {\n output: 'export',\n};")],
1388 framework: Some("nextjs"),
1389 output_dir: Some("out"),
1390 ..CASE
1391 },
1392 Case {
1393 name: "next-server",
1394 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.mjs", "export default {}")],
1395 kind: "none",
1396 framework: Some("nextjs"),
1397 build: None,
1398 problem: Some("`@opennextjs/cloudflare`"),
1399 ..CASE
1400 },
1401 Case {
1402 name: "next-server-own-folder",
1403 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#)],
1404 chosen_output: Some("out"),
1405 framework: Some("nextjs"),
1406 output_dir: Some("out"),
1407 ..CASE
1408 },
1409 Case {
1410 name: "opennext",
1411 files: &[
1412 ("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#),
1413 ("wrangler.jsonc", r#"{ "main": ".open-next/worker.js", "assets": { "directory": ".open-next/assets", "binding": "ASSETS" } }"#),
1414 ],
1415 kind: "workers",
1416 framework: Some("nextjs"),
1417 build: Some("npx opennextjs-cloudflare build && npx --yes wrangler@4 deploy --dry-run"),
1418 output_dir: Some(".open-next/assets"),
1419 ..CASE
1420 },
1421 Case {
1422 name: "opennext-no-config",
1423 files: &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)],
1424 kind: "none",
1425 framework: Some("nextjs"),
1426 build: None,
1427 problem: Some("`.open-next/worker.js`"),
1428 ..CASE
1429 },
1430 Case {
1431 name: "sveltekit-static",
1432 files: &[
1433 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-static": "3", "vite": "7" } }"#),
1434 ("svelte.config.js", "import adapter from '@sveltejs/adapter-static';\nexport default { kit: { adapter: adapter() } };"),
1435 ],
1436 framework: Some("sveltekit"),
1437 output_dir: Some("build"),
1438 ..CASE
1439 },
1440 Case {
1441 name: "sveltekit-3-static",
1442 files: &[
1443 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "3", "@sveltejs/adapter-static": "4", "@sveltejs/adapter-cloudflare": "8" } }"#),
1444 ("vite.config.js", "import adapter from '@sveltejs/adapter-static';\nexport default defineConfig({ plugins: [sveltekit({ adapter: adapter({ pages: 'www' }) })] });"),
1445 ],
1446 framework: Some("sveltekit"),
1447 output_dir: Some("www"),
1448 ..CASE
1449 },
1450 Case {
1451 name: "sveltekit-cloudflare",
1452 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-cloudflare": "7" } }"#)],
1453 kind: "workers",
1454 framework: Some("sveltekit"),
1455 ..CASE
1456 },
1457 Case {
1458 name: "sveltekit-auto",
1459 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-auto": "6" } }"#)],
1460 kind: "none",
1461 framework: Some("sveltekit"),
1462 build: None,
1463 problem: Some("`@sveltejs/adapter-static`"),
1464 ..CASE
1465 },
1466 Case {
1467 name: "nuxt",
1468 files: &[("package.json", r#"{ "scripts": { "build": "nuxt build", "generate": "nuxt generate" }, "dependencies": { "nuxt": "4" } }"#)],
1469 framework: Some("nuxt"),
1470 build: Some("npm run generate"),
1471 output_dir: Some(".output/public"),
1472 ..CASE
1473 },
1474 Case {
1475 name: "nuxt-no-generate",
1476 files: &[("package.json", r#"{ "scripts": { "build": "nuxt build" }, "dependencies": { "nuxt": "4" } }"#)],
1477 framework: Some("nuxt"),
1478 build: Some("npx nuxi generate"),
1479 output_dir: Some(".output/public"),
1480 ..CASE
1481 },
1482 Case {
1483 name: "react-router-spa",
1484 files: &[
1485 ("package.json", r#"{ "scripts": { "build": "react-router build" }, "devDependencies": { "@react-router/dev": "7", "vite": "7" } }"#),
1486 ("react-router.config.ts", "export default {\n // Server-side render by default\n ssr: false,\n} satisfies Config;"),
1487 ],
1488 framework: Some("react-router"),
1489 output_dir: Some("build/client"),
1490 ..CASE
1491 },
1492 Case {
1493 name: "react-router-ssr",
1494 files: &[("package.json", r#"{ "devDependencies": { "@react-router/dev": "7" } }"#), ("react-router.config.ts", "export default { ssr: true }")],
1495 kind: "none",
1496 framework: Some("react-router"),
1497 build: None,
1498 problem: Some("`ssr: false`"),
1499 ..CASE
1500 },
1501 Case {
1502 name: "remix-spa",
1503 files: &[
1504 ("package.json", r#"{ "scripts": { "build": "remix vite:build" }, "devDependencies": { "@remix-run/dev": "2", "vite": "5" } }"#),
1505 ("vite.config.ts", "export default defineConfig({ plugins: [remix({ ssr: false })] })"),
1506 ],
1507 framework: Some("remix"),
1508 output_dir: Some("build/client"),
1509 ..CASE
1510 },
1511 Case {
1512 name: "gatsby",
1513 files: &[("package.json", r#"{ "scripts": { "build": "gatsby build" }, "dependencies": { "gatsby": "5" } }"#)],
1514 framework: Some("gatsby"),
1515 output_dir: Some("public"),
1516 ..CASE
1517 },
1518 Case {
1519 name: "docusaurus",
1520 files: &[("package.json", r#"{ "scripts": { "build": "docusaurus build" }, "dependencies": { "@docusaurus/core": "3" } }"#)],
1521 framework: Some("docusaurus"),
1522 output_dir: Some("build"),
1523 ..CASE
1524 },
1525 Case {
1526 name: "eleventy",
1527 files: &[("package.json", r#"{ "devDependencies": { "@11ty/eleventy": "3" } }"#)],
1528 framework: Some("eleventy"),
1529 build: Some("npx @11ty/eleventy"),
1530 output_dir: Some("_site"),
1531 ..CASE
1532 },
1533 Case {
1534 name: "angular",
1535 files: &[
1536 ("package.json", r#"{ "scripts": { "build": "ng build" }, "dependencies": { "@angular/core": "20" } }"#),
1537 ("angular.json", r#"{ "projects": { "shop": { "projectType": "application", "architect": { "build": { "builder": "@angular/build:application", "options": { "outputPath": "dist/shop" } } } } } }"#),
1538 ],
1539 framework: Some("angular"),
1540 output_dir: Some("dist/shop/browser"),
1541 ..CASE
1542 },
1543 Case {
1544 name: "angular-default",
1545 files: &[
1546 ("package.json", r#"{ "dependencies": { "@angular/core": "20" } }"#),
1547 ("angular.json", r#"{ "projects": { "app": { "projectType": "application", "architect": { "build": { "builder": "@angular/build:application", "options": {} } } } } }"#),
1548 ],
1549 framework: Some("angular"),
1550 build: Some("npx ng build"),
1551 output_dir: Some("dist/app/browser"),
1552 ..CASE
1553 },
1554 Case {
1555 name: "hugo",
1556 files: &[("hugo.toml", "baseURL = 'https://example.org/'\ntitle = 'Site'\n")],
1557 framework: Some("hugo"),
1558 build: Some(frameworks_hugo()),
1559 output_dir: Some("public"),
1560 ..CASE
1561 },
1562 Case {
1563 name: "hugo-config-toml",
1564 files: &[("config.toml", "baseURL = \"https://example.org/\"\npublishDir = \"site\"\n")],
1565 framework: Some("hugo"),
1566 build: Some(frameworks_hugo()),
1567 output_dir: Some("site"),
1568 ..CASE
1569 },
1570 Case {
1571 name: "not-hugo",
1572 files: &[("config.toml", "base_url = \"https://example.org\"\n"), ("index.html", "")],
1573 kind: "html",
1574 build: None,
1575 output_dir: Some("."),
1576 ..CASE
1577 },
1578 Case {
1579 name: "jekyll",
1580 files: &[("_config.yml", "title: Blog\n"), ("Gemfile", "gem 'jekyll'\n")],
1581 framework: Some("jekyll"),
1582 build: Some("bundle exec jekyll build"),
1583 output_dir: Some("_site"),
1584 ..CASE
1585 },
1586 Case {
1587 name: "mdbook",
1588 files: &[("book.toml", "[book]\ntitle = \"Guide\"\n")],
1589 framework: Some("mdbook"),
1590 build: Some(frameworks_mdbook()),
1591 output_dir: Some("book"),
1592 ..CASE
1593 },
1594 Case {
1595 name: "mdbook-build-dir",
1596 files: &[("book.toml", "[book]\ntitle = \"Guide\"\n[build]\nbuild-dir = \"site\"\n")],
1597 framework: Some("mdbook"),
1598 build: Some(frameworks_mdbook()),
1599 output_dir: Some("site"),
1600 ..CASE
1601 },
1602 // The project's own settings win.
1603 Case {
1604 name: "own-settings",
1605 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7" } }"#)],
1606 custom_build: Some("make site"),
1607 chosen_output: Some("www"),
1608 framework: Some("vite"),
1609 build: Some("make site"),
1610 output_dir: Some("www"),
1611 ..CASE
1612 },
1613 ];
1614 for case in cases {
1615 let dir = project(&format!("fw-{}", case.name), case.files);
1616 let found = detect(&dir, case.custom_build, case.chosen_output).unwrap();
1617 let name = case.name;
1618 assert_eq!(found.kind, case.kind, "{name}: kind");
1619 assert_eq!(found.framework.as_ref().map(|f| f.id), case.framework, "{name}: framework");
1620 assert_eq!(found.build.as_deref(), case.build, "{name}: build");
1621 assert_eq!(found.output_dir.as_deref(), case.output_dir, "{name}: output-dir");
1622 match case.problem {
1623 Some(phrase) => assert!(found.problem.as_deref().is_some_and(|p| p.contains(phrase)), "{name}: {:?}", found.problem),
1624 None => assert_eq!(found.problem, None, "{name}: problem"),
1625 }
1626 let _ = std::fs::remove_dir_all(&dir);
1627 }
1628 }
1629
1630 fn frameworks_hugo() -> &'static str {
1631 "if command -v hugo >/dev/null 2>&1; then hugo --minify; else npx --yes hugo-extended@0.166.0 --minify; fi"
1632 }
1633
1634 fn frameworks_mdbook() -> &'static str {
1635 "if ! command -v mdbook >/dev/null 2>&1; then cargo install mdbook --version 0.5.4 --locked --quiet; fi && mdbook build"
1636 }
1637
1638 #[test]
1639 fn jekyll_installs_its_gems_after_the_packages() {
1640 let dir = project("jekyll-install", &[("_config.yml", ""), ("Gemfile", ""), ("package.json", "{}")]);
1641 let found = detect(&dir, None, None).unwrap();
1642 assert_eq!(found.install.as_deref(), Some("npm install && bundle config set --local path vendor/bundle && bundle install"));
1643 }
1644
1645 #[test]
1646 fn an_adapters_worker_is_found_once_built_and_never_served() {
1647 let dir = project(
1648 "adapter",
1649 &[
1650 ("dist/_worker.js/index.js", "export default {}"),
1651 ("dist/_routes.json", "{}"),
1652 ("dist/.assetsignore", "_worker.js\nsecret.txt\n"),
1653 ("dist/secret.txt", "no"),
1654 ("dist/index.html", "<h1>hi</h1>"),
1655 ("dist/about/_worker.js", "a page's own file"),
1656 ("kit/.svelte-kit/cloudflare/_worker.js", "export default {}"),
1657 ],
1658 );
1659 let astro = Adapter { entry: "dist/_worker.js", assets: "dist" };
1660 assert_eq!(adapter_entry(&dir, &astro), Some(dir.join("dist/_worker.js/index.js")));
1661 let kit = Adapter { entry: ".svelte-kit/cloudflare/_worker.js", assets: ".svelte-kit/cloudflare" };
1662 assert_eq!(adapter_entry(&dir.join("kit"), &kit), Some(dir.join("kit/.svelte-kit/cloudflare/_worker.js")));
1663 assert_eq!(adapter_entry(&dir.join("missing"), &kit), None);
1664 let mut served: Vec<String> = collect_site(&dir.join("dist"), false).unwrap().into_iter().map(|a| a.path).collect();
1665 served.sort();
1666 assert_eq!(served, ["/about/_worker.js", "/index.html"]);
1667 }
1668
1669 #[test]
1670 fn a_config_the_build_wrote_is_read_in_place_of_the_projects() {
1671 let dir = project(
1672 "redirected",
1673 &[
1674 ("wrangler.jsonc", r#"{ "main": "worker/index.ts" }"#),
1675 (".wrangler/deploy/config.json", r#"{ "configPath": "../../dist/app/wrangler.json" }"#),
1676 ("dist/app/wrangler.json", r#"{ "main": "index.js", "assets": { "directory": "../client" } }"#),
1677 ],
1678 );
1679 let (config, base) = redirected_config(&dir).unwrap().unwrap();
1680 assert_eq!(config.main.as_deref(), Some("index.js"));
1681 assert_eq!(base, dir.join("dist/app").canonicalize().unwrap());
1682 assert!(base.join(config.assets.unwrap().directory.unwrap()).ends_with("dist/app/../client"));
1683 assert!(redirected_config(&project("not-redirected", &[])).unwrap().is_none());
1684 }
1685
1686 #[test]
1687 fn the_root_directory_stays_inside_the_checkout() {
1688 let base = Path::new("/w/repo");
1689 assert_eq!(project_dir_in(base, ""), base);
1690 assert_eq!(project_dir_in(base, "."), base);
1691 assert_eq!(project_dir_in(base, "/apps/site/"), base.join("apps/site"));
1692 assert_eq!(project_dir_in(base, "../etc"), base);
1693 assert_eq!(project_dir_in(base, "apps/../../x"), base);
1694 }
1695
1696 #[test]
1697 fn a_step_prints_the_log_redacted() {
1698 let mut log = Log { echo: Some(vec!["hunter22".to_owned()]), ..Log::default() };
1699 log.line("token hunter22 used");
1700 assert!(log.text.contains("hunter22"), "the deployment's log is redacted when it is sent");
1701 assert_eq!(wrangler_build(Some(Path::new("/tmp/b")), None), "npx --yes wrangler@4 deploy --dry-run --outdir /tmp/b");
1702 assert_eq!(
1703 wrangler_build(Some(Path::new("/tmp/b")), Some(Path::new("/tmp/w.json"))),
1704 "npx --yes wrangler@4 deploy --dry-run --config /tmp/w.json --outdir /tmp/b"
1705 );
1706 }
1707}