| 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 | |
| 52 | use std::collections::{BTreeMap, BTreeSet}; |
| 53 | use std::path::{Path, PathBuf}; |
| 54 | use std::time::Instant; |
| 55 | |
| 56 | use anyhow::{Context, Result, bail}; |
| 57 | use base64::Engine; |
| 58 | use base64::engine::general_purpose::STANDARD; |
| 59 | use serde::{Deserialize, Serialize}; |
| 60 | use serde_json::{Value, json}; |
| 61 | use sha2::{Digest, Sha256}; |
| 62 | |
| 63 | use crate::checks::{redact, run_command}; |
| 64 | use crate::{WORKDIR, auth_option, env, git}; |
| 65 | |
| 66 | mod frameworks; |
| 67 | |
| 68 | use frameworks::{Adapter, Framework}; |
| 69 | |
| 70 | /// Where `wrangler deploy --dry-run` writes the bundle in a sandbox. |
| 71 | const BUNDLE_DIR: &str = "/work/g1t-bundle"; |
| 72 | /// Cloudflare's limits on a Worker's static assets. |
| 73 | const MAX_FILES: usize = 20_000; |
| 74 | const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024; |
| 75 | /// How much of the build's output is kept for the deployment's log. |
| 76 | const MAX_LOG_CHARS: usize = 20_000; |
| 77 | /// Directories a static build usually writes to, in the order they are tried. |
| 78 | const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"]; |
| 79 | /// Bindings a Workers project may declare that are not provisioned yet. |
| 80 | const 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 | |
| 93 | struct Reporter { |
| 94 | base: String, |
| 95 | token: String, |
| 96 | } |
| 97 | |
| 98 | impl 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)] |
| 110 | struct Log { |
| 111 | text: String, |
| 112 | /// Each line to standard output as well, redacted. |
| 113 | echo: Option<Vec<String>>, |
| 114 | } |
| 115 | |
| 116 | impl 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. |
| 136 | fn 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)] |
| 150 | struct 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)] |
| 163 | struct 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. |
| 171 | fn 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 | |
| 236 | fn 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. |
| 255 | fn 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 | |
| 272 | fn 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. |
| 280 | struct Asset { |
| 281 | path: String, |
| 282 | hash: String, |
| 283 | size: u64, |
| 284 | file: PathBuf, |
| 285 | } |
| 286 | |
| 287 | fn 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. |
| 318 | fn 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. |
| 332 | fn 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 | |
| 338 | fn 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)] |
| 383 | struct 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. |
| 393 | fn 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")] |
| 429 | struct 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. |
| 436 | fn 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 | |
| 475 | fn 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. |
| 490 | fn 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. |
| 523 | struct 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. |
| 536 | fn 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. |
| 542 | fn 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. |
| 557 | fn 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`. |
| 572 | fn 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. |
| 582 | fn 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)] |
| 599 | pub(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 | |
| 615 | pub(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. |
| 683 | const 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). |
| 688 | fn 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. |
| 721 | fn 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 | |
| 726 | fn 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. |
| 851 | fn 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. |
| 857 | fn 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. |
| 867 | fn 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. |
| 877 | fn 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. |
| 887 | fn project_dir() -> PathBuf { |
| 888 | project_dir_in(&checkout_dir(), &std::env::var("ROOT_DIR").unwrap_or_default()) |
| 889 | } |
| 890 | |
| 891 | fn 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 | |
| 909 | fn 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`). |
| 979 | pub 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. |
| 986 | pub 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 | |
| 994 | const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page. |
| 995 | |
| 996 | Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds), |
| 997 | G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR, |
| 998 | BUILD_ENV and BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. |
| 999 | |
| 1000 | g1t-runner detect prints what the build would find, as key=value lines."; |
| 1001 | |
| 1002 | fn 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. |
| 1072 | pub 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. |
| 1111 | fn 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)] |
| 1127 | mod 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] |
| 1173 | crons = [\"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 | } |