g1t/crates/runner/src/deploy.rs
| 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 | //! - Anything else: a static site. Its `build` script runs, and the first |
| 18 | //! of `dist`, `build`, `out`, `public`, `_site` or `.output/public` that |
| 19 | //! exists is served, or the repository itself if it has an `index.html`. |
| 20 | //! |
| 21 | //! Configuration comes from the environment: |
| 22 | //! |
| 23 | //! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report. |
| 24 | //! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out. |
| 25 | //! - `ROOT_DIR`: where in the repository the project lives; empty for all |
| 26 | //! of it. Everything below is relative to it. |
| 27 | //! - `BUILD_COMMAND`, `OUTPUT_DIR`: the project's own choices, if any. |
| 28 | //! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's |
| 29 | //! variables and secrets for deploy builds. Both are set for the build; |
| 30 | //! secrets' values are redacted from its log. |
| 31 | |
| 32 | use std::collections::BTreeMap; |
| 33 | use std::path::{Path, PathBuf}; |
| 34 | use std::time::Instant; |
| 35 | |
| 36 | use anyhow::{Context, Result, bail}; |
| 37 | use base64::Engine; |
| 38 | use base64::engine::general_purpose::STANDARD; |
| 39 | use serde::{Deserialize, Serialize}; |
| 40 | use serde_json::{Value, json}; |
| 41 | use sha2::{Digest, Sha256}; |
| 42 | |
| 43 | use crate::checks::{redact, run_command}; |
| 44 | use crate::{WORKDIR, auth_option, env, git}; |
| 45 | |
| 46 | /// Where `wrangler deploy --dry-run` writes the bundle. |
| 47 | const BUNDLE_DIR: &str = "/work/g1t-bundle"; |
| 48 | /// Cloudflare's limits on a Worker's static assets. |
| 49 | const MAX_FILES: usize = 20_000; |
| 50 | const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024; |
| 51 | /// How much of the build's output is kept for the deployment's log. |
| 52 | const MAX_LOG_CHARS: usize = 20_000; |
| 53 | /// Directories a static build usually writes to, in the order they are tried. |
| 54 | const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"]; |
| 55 | /// Bindings a Workers project may declare that are not provisioned yet. |
| 56 | const UNSUPPORTED_BINDINGS: [&str; 10] = [ |
| 57 | "kv_namespaces", |
| 58 | "d1_databases", |
| 59 | "r2_buckets", |
| 60 | "durable_objects", |
| 61 | "services", |
| 62 | "queues", |
| 63 | "vectorize", |
| 64 | "hyperdrive", |
| 65 | "ai", |
| 66 | "workflows", |
| 67 | ]; |
| 68 | |
| 69 | struct Reporter { |
| 70 | base: String, |
| 71 | token: String, |
| 72 | } |
| 73 | |
| 74 | impl Reporter { |
| 75 | fn send(&self, step: &str, mut body: Value) -> Result<Value> { |
| 76 | body["token"] = self.token.clone().into(); |
| 77 | let response = ureq::post(&format!("{}/{step}", self.base)) |
| 78 | .send_json(body) |
| 79 | .with_context(|| format!("could not report `{step}` to g1t"))?; |
| 80 | Ok(response.into_json().unwrap_or(Value::Null)) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | /// The build's log, kept to its end. |
| 85 | #[derive(Default)] |
| 86 | struct Log { |
| 87 | text: String, |
| 88 | } |
| 89 | |
| 90 | impl Log { |
| 91 | fn line(&mut self, line: &str) { |
| 92 | self.text.push_str(line); |
| 93 | self.text.push('\n'); |
| 94 | } |
| 95 | |
| 96 | fn tail(&self) -> String { |
| 97 | let length = self.text.chars().count(); |
| 98 | if length <= MAX_LOG_CHARS { |
| 99 | return self.text.clone(); |
| 100 | } |
| 101 | let kept: String = self.text.chars().skip(length - MAX_LOG_CHARS).collect(); |
| 102 | format!("… (earlier output not shown)\n{kept}") |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | /// Runs a command in the checkout, logging it; fails if it fails. |
| 107 | fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> { |
| 108 | log.line(&format!("$ {command}")); |
| 109 | let result = run_command(command, &project_dir(), secrets); |
| 110 | if !result.output_text().is_empty() { |
| 111 | log.line(result.output_text()); |
| 112 | } |
| 113 | if !result.passed { |
| 114 | bail!("`{command}` failed"); |
| 115 | } |
| 116 | Ok(()) |
| 117 | } |
| 118 | |
| 119 | /// A Workers project's settings, from whichever config file it has. |
| 120 | #[derive(Debug, Default, Deserialize)] |
| 121 | struct WranglerConfig { |
| 122 | main: Option<String>, |
| 123 | compatibility_date: Option<String>, |
| 124 | #[serde(default)] |
| 125 | compatibility_flags: Vec<String>, |
| 126 | assets: Option<AssetsConfig>, |
| 127 | #[serde(default)] |
| 128 | vars: BTreeMap<String, Value>, |
| 129 | #[serde(flatten)] |
| 130 | rest: BTreeMap<String, Value>, |
| 131 | } |
| 132 | |
| 133 | #[derive(Debug, Default, Deserialize)] |
| 134 | struct AssetsConfig { |
| 135 | directory: Option<String>, |
| 136 | binding: Option<String>, |
| 137 | html_handling: Option<String>, |
| 138 | not_found_handling: Option<String>, |
| 139 | } |
| 140 | |
| 141 | /// JSON with comments and trailing commas, as `wrangler.jsonc` allows. |
| 142 | fn strip_jsonc(text: &str) -> String { |
| 143 | let mut out = String::with_capacity(text.len()); |
| 144 | let mut chars = text.chars().peekable(); |
| 145 | let mut in_string = false; |
| 146 | while let Some(c) = chars.next() { |
| 147 | if in_string { |
| 148 | out.push(c); |
| 149 | if c == '\\' { |
| 150 | if let Some(next) = chars.next() { |
| 151 | out.push(next); |
| 152 | } |
| 153 | } else if c == '"' { |
| 154 | in_string = false; |
| 155 | } |
| 156 | continue; |
| 157 | } |
| 158 | match (c, chars.peek()) { |
| 159 | ('"', _) => { |
| 160 | in_string = true; |
| 161 | out.push(c); |
| 162 | } |
| 163 | ('/', Some('/')) => { |
| 164 | for c in chars.by_ref() { |
| 165 | if c == '\n' { |
| 166 | out.push('\n'); |
| 167 | break; |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | ('/', Some('*')) => { |
| 172 | chars.next(); |
| 173 | let mut last = ' '; |
| 174 | for c in chars.by_ref() { |
| 175 | if last == '*' && c == '/' { |
| 176 | break; |
| 177 | } |
| 178 | last = c; |
| 179 | } |
| 180 | } |
| 181 | _ => out.push(c), |
| 182 | } |
| 183 | } |
| 184 | // Trailing commas before a closing bracket. |
| 185 | let mut cleaned = String::with_capacity(out.len()); |
| 186 | let chars: Vec<char> = out.chars().collect(); |
| 187 | let mut in_string = false; |
| 188 | let mut i = 0; |
| 189 | while i < chars.len() { |
| 190 | let c = chars[i]; |
| 191 | if c == '"' && (i == 0 || chars[i - 1] != '\\') { |
| 192 | in_string = !in_string; |
| 193 | } |
| 194 | if c == ',' && !in_string { |
| 195 | let next = chars[i + 1..].iter().find(|c| !c.is_whitespace()); |
| 196 | if matches!(next, Some('}') | Some(']')) { |
| 197 | i += 1; |
| 198 | continue; |
| 199 | } |
| 200 | } |
| 201 | cleaned.push(c); |
| 202 | i += 1; |
| 203 | } |
| 204 | cleaned |
| 205 | } |
| 206 | |
| 207 | fn read_wrangler(dir: &Path) -> Result<Option<WranglerConfig>> { |
| 208 | for name in ["wrangler.jsonc", "wrangler.json"] { |
| 209 | let path = dir.join(name); |
| 210 | if path.exists() { |
| 211 | let text = std::fs::read_to_string(&path)?; |
| 212 | return Ok(Some( |
| 213 | serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {name}"))?, |
| 214 | )); |
| 215 | } |
| 216 | } |
| 217 | let path = dir.join("wrangler.toml"); |
| 218 | if path.exists() { |
| 219 | let text = std::fs::read_to_string(&path)?; |
| 220 | return Ok(Some(toml::from_str(&text).context("could not read wrangler.toml")?)); |
| 221 | } |
| 222 | Ok(None) |
| 223 | } |
| 224 | |
| 225 | /// How to install the project's dependencies, judged by its lockfile. |
| 226 | fn install_command(dir: &Path) -> Option<&'static str> { |
| 227 | if !dir.join("package.json").exists() { |
| 228 | return None; |
| 229 | } |
| 230 | Some(if dir.join("pnpm-lock.yaml").exists() { |
| 231 | "corepack enable && pnpm install --frozen-lockfile" |
| 232 | } else if dir.join("yarn.lock").exists() { |
| 233 | "corepack enable && yarn install" |
| 234 | } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() { |
| 235 | "npx --yes bun install" |
| 236 | } else if dir.join("package-lock.json").exists() { |
| 237 | "npm ci" |
| 238 | } else { |
| 239 | "npm install" |
| 240 | }) |
| 241 | } |
| 242 | |
| 243 | fn has_build_script(dir: &Path) -> bool { |
| 244 | std::fs::read_to_string(dir.join("package.json")) |
| 245 | .ok() |
| 246 | .and_then(|text| serde_json::from_str::<Value>(&text).ok()) |
| 247 | .is_some_and(|package| package["scripts"]["build"].is_string()) |
| 248 | } |
| 249 | |
| 250 | /// One file of the site, as Cloudflare's asset upload names it. |
| 251 | struct Asset { |
| 252 | path: String, |
| 253 | hash: String, |
| 254 | size: u64, |
| 255 | file: PathBuf, |
| 256 | } |
| 257 | |
| 258 | fn content_type(path: &str) -> &'static str { |
| 259 | let extension = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); |
| 260 | match extension.as_str() { |
| 261 | "html" | "htm" => "text/html", |
| 262 | "css" => "text/css", |
| 263 | "js" | "mjs" => "application/javascript", |
| 264 | "json" | "map" => "application/json", |
| 265 | "svg" => "image/svg+xml", |
| 266 | "png" => "image/png", |
| 267 | "jpg" | "jpeg" => "image/jpeg", |
| 268 | "gif" => "image/gif", |
| 269 | "webp" => "image/webp", |
| 270 | "avif" => "image/avif", |
| 271 | "ico" => "image/x-icon", |
| 272 | "woff" => "font/woff", |
| 273 | "woff2" => "font/woff2", |
| 274 | "ttf" => "font/ttf", |
| 275 | "txt" => "text/plain", |
| 276 | "xml" => "application/xml", |
| 277 | "wasm" => "application/wasm", |
| 278 | "pdf" => "application/pdf", |
| 279 | "mp4" => "video/mp4", |
| 280 | "webm" => "video/webm", |
| 281 | _ => "application/octet-stream", |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | /// Every file under `root`, but for what never belongs in a site. |
| 286 | fn collect(root: &Path, dir: &Path, skip_project: bool, out: &mut Vec<Asset>) -> Result<()> { |
| 287 | for entry in std::fs::read_dir(dir)? { |
| 288 | let entry = entry?; |
| 289 | let name = entry.file_name().to_string_lossy().into_owned(); |
| 290 | let path = entry.path(); |
| 291 | let kind = entry.file_type()?; |
| 292 | if name == ".git" || (skip_project && (name == "node_modules" || name.starts_with(".g1t"))) { |
| 293 | continue; |
| 294 | } |
| 295 | if kind.is_dir() { |
| 296 | collect(root, &path, skip_project, out)?; |
| 297 | continue; |
| 298 | } |
| 299 | if !kind.is_file() || name == "_headers" || name == "_redirects" { |
| 300 | continue; |
| 301 | } |
| 302 | let size = entry.metadata()?.len(); |
| 303 | let relative = path |
| 304 | .strip_prefix(root)? |
| 305 | .to_string_lossy() |
| 306 | .replace('\\', "/"); |
| 307 | if size > MAX_FILE_BYTES { |
| 308 | bail!("{relative} is larger than Cloudflare's 25 MiB limit for one file"); |
| 309 | } |
| 310 | let bytes = std::fs::read(&path)?; |
| 311 | let digest = hex::encode(Sha256::digest(&bytes)); |
| 312 | out.push(Asset { |
| 313 | path: format!("/{relative}"), |
| 314 | hash: digest[..32].to_owned(), |
| 315 | size, |
| 316 | file: path, |
| 317 | }); |
| 318 | if out.len() > MAX_FILES { |
| 319 | bail!("the site has more than {MAX_FILES} files, Cloudflare's limit"); |
| 320 | } |
| 321 | } |
| 322 | Ok(()) |
| 323 | } |
| 324 | |
| 325 | #[derive(Deserialize)] |
| 326 | #[serde(rename_all = "camelCase")] |
| 327 | struct UploadSession { |
| 328 | jwt: String, |
| 329 | #[serde(default)] |
| 330 | buckets: Vec<Vec<String>>, |
| 331 | upload_url: String, |
| 332 | } |
| 333 | |
| 334 | /// Sends one bucket of files with the upload's key. The last bucket's |
| 335 | /// answer carries the key that completes the upload. |
| 336 | fn upload_bucket(session: &UploadSession, bucket: &[String], by_hash: &BTreeMap<&str, &Asset>) -> Result<Option<String>> { |
| 337 | let boundary = format!("g1t-{}", hex::encode(Sha256::digest(bucket.join(",").as_bytes()))[..24].to_owned()); |
| 338 | let mut body: Vec<u8> = Vec::new(); |
| 339 | for hash in bucket { |
| 340 | let asset = by_hash |
| 341 | .get(hash.as_str()) |
| 342 | .with_context(|| format!("Cloudflare asked for a file this build does not have ({hash})"))?; |
| 343 | let bytes = std::fs::read(&asset.file)?; |
| 344 | body.extend_from_slice( |
| 345 | format!( |
| 346 | "--{boundary}\r\nContent-Disposition: form-data; name=\"{hash}\"; filename=\"{hash}\"\r\nContent-Type: {}\r\n\r\n", |
| 347 | content_type(&asset.path) |
| 348 | ) |
| 349 | .as_bytes(), |
| 350 | ); |
| 351 | body.extend_from_slice(STANDARD.encode(bytes).as_bytes()); |
| 352 | body.extend_from_slice(b"\r\n"); |
| 353 | } |
| 354 | body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); |
| 355 | let response = ureq::post(&session.upload_url) |
| 356 | .set("authorization", &format!("Bearer {}", session.jwt)) |
| 357 | .set("content-type", &format!("multipart/form-data; boundary={boundary}")) |
| 358 | .send_bytes(&body); |
| 359 | let response = match response { |
| 360 | Ok(response) => response, |
| 361 | Err(ureq::Error::Status(code, response)) => { |
| 362 | bail!("Cloudflare refused the upload ({code}): {}", response.into_string().unwrap_or_default()) |
| 363 | } |
| 364 | Err(error) => bail!("could not upload to Cloudflare: {error}"), |
| 365 | }; |
| 366 | let answer: Value = response.into_json().unwrap_or(Value::Null); |
| 367 | Ok(answer["result"]["jwt"].as_str().map(str::to_owned)) |
| 368 | } |
| 369 | |
| 370 | #[derive(Serialize)] |
| 371 | #[serde(rename_all = "camelCase")] |
| 372 | struct Module { |
| 373 | name: String, |
| 374 | content_base64: String, |
| 375 | content_type: String, |
| 376 | } |
| 377 | |
| 378 | /// The bundle `wrangler deploy --dry-run` wrote, main module first. |
| 379 | fn bundle_modules(main: &str) -> Result<(String, Vec<Module>)> { |
| 380 | let stem = Path::new(main) |
| 381 | .file_stem() |
| 382 | .map(|stem| stem.to_string_lossy().into_owned()) |
| 383 | .unwrap_or_else(|| "index".to_owned()); |
| 384 | let mut modules = Vec::new(); |
| 385 | let mut files = Vec::new(); |
| 386 | collect_files(Path::new(BUNDLE_DIR), &mut files)?; |
| 387 | for file in files { |
| 388 | let name = file |
| 389 | .strip_prefix(BUNDLE_DIR)? |
| 390 | .to_string_lossy() |
| 391 | .replace('\\', "/"); |
| 392 | let kind = match name.rsplit('.').next().unwrap_or("") { |
| 393 | "js" | "mjs" => "application/javascript+module", |
| 394 | "wasm" => "application/wasm", |
| 395 | "map" | "md" => continue, |
| 396 | _ => "text/plain", |
| 397 | }; |
| 398 | modules.push(Module { |
| 399 | content_base64: STANDARD.encode(std::fs::read(&file)?), |
| 400 | content_type: kind.to_owned(), |
| 401 | name, |
| 402 | }); |
| 403 | } |
| 404 | let main_name = modules |
| 405 | .iter() |
| 406 | .map(|module| module.name.clone()) |
| 407 | .find(|name| *name == format!("{stem}.js") || *name == format!("{stem}.mjs")) |
| 408 | .or_else(|| { |
| 409 | modules |
| 410 | .iter() |
| 411 | .find(|module| module.content_type == "application/javascript+module") |
| 412 | .map(|module| module.name.clone()) |
| 413 | }) |
| 414 | .context("wrangler wrote no JavaScript module")?; |
| 415 | Ok((main_name, modules)) |
| 416 | } |
| 417 | |
| 418 | fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> { |
| 419 | for entry in std::fs::read_dir(dir)? { |
| 420 | let entry = entry?; |
| 421 | if entry.file_type()?.is_dir() { |
| 422 | collect_files(&entry.path(), out)?; |
| 423 | } else { |
| 424 | out.push(entry.path()); |
| 425 | } |
| 426 | } |
| 427 | Ok(()) |
| 428 | } |
| 429 | |
| 430 | /// What was built: the Worker's code and settings, and where its site is. |
| 431 | struct Built { |
| 432 | worker: Value, |
| 433 | assets_dir: Option<PathBuf>, |
| 434 | warnings: Vec<String>, |
| 435 | } |
| 436 | |
| 437 | fn build(log: &mut Log, secrets: &[String]) -> Result<Built> { |
| 438 | let project = project_dir(); |
| 439 | let dir = project.as_path(); |
| 440 | let config = read_wrangler(dir)?; |
| 441 | let custom_build = std::env::var("BUILD_COMMAND").ok().filter(|c| !c.trim().is_empty()); |
| 442 | if let Some(install) = install_command(dir) { |
| 443 | step(log, install, secrets)?; |
| 444 | } |
| 445 | let mut warnings = Vec::new(); |
| 446 | match config { |
| 447 | Some(config) => { |
| 448 | if let Some(command) = &custom_build { |
| 449 | step(log, command, secrets)?; |
| 450 | } |
| 451 | for binding in UNSUPPORTED_BINDINGS { |
| 452 | if config.rest.get(binding).is_some_and(|value| !value.is_null()) { |
| 453 | warnings.push(format!( |
| 454 | "`{binding}` is not provisioned on g1t.page yet, so the app runs without it." |
| 455 | )); |
| 456 | } |
| 457 | } |
| 458 | let mut worker = json!({ |
| 459 | "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| "2026-09-26".to_owned()), |
| 460 | "compatibilityFlags": config.compatibility_flags, |
| 461 | "vars": config.vars, |
| 462 | }); |
| 463 | if let Some(main) = &config.main { |
| 464 | // `--dry-run` runs the project's own build and bundles it, |
| 465 | // without deploying anywhere. |
| 466 | step( |
| 467 | log, |
| 468 | &format!("npx --yes wrangler@4 deploy --dry-run --outdir {BUNDLE_DIR}"), |
| 469 | secrets, |
| 470 | )?; |
| 471 | let (main_module, modules) = bundle_modules(main)?; |
| 472 | worker["mainModule"] = main_module.into(); |
| 473 | worker["modules"] = serde_json::to_value(modules)?; |
| 474 | } |
| 475 | let assets = config.assets.unwrap_or_default(); |
| 476 | let assets_dir = assets.directory.as_ref().map(|directory| dir.join(directory)); |
| 477 | worker["assetsBinding"] = assets.binding.into(); |
| 478 | worker["htmlHandling"] = assets.html_handling.into(); |
| 479 | worker["notFoundHandling"] = assets.not_found_handling.into(); |
| 480 | Ok(Built { |
| 481 | worker, |
| 482 | assets_dir, |
| 483 | warnings, |
| 484 | }) |
| 485 | } |
| 486 | None => { |
| 487 | if let Some(command) = &custom_build { |
| 488 | step(log, command, secrets)?; |
| 489 | } else if has_build_script(dir) { |
| 490 | step(log, "npm run build", secrets)?; |
| 491 | } |
| 492 | let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty()); |
| 493 | let assets_dir = match chosen { |
| 494 | Some(chosen) => { |
| 495 | let path = dir.join(chosen.trim_matches('/')); |
| 496 | if !path.is_dir() { |
| 497 | bail!("the output directory `{chosen}` does not exist after the build"); |
| 498 | } |
| 499 | path |
| 500 | } |
| 501 | None => OUTPUT_DIRS |
| 502 | .iter() |
| 503 | .map(|name| dir.join(name)) |
| 504 | .find(|path| path.join("index.html").exists() || (path.is_dir() && path != &dir.join("public"))) |
| 505 | .or_else(|| dir.join("index.html").exists().then(|| dir.to_path_buf())) |
| 506 | .context( |
| 507 | "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public", |
| 508 | )?, |
| 509 | }; |
| 510 | let spa = !assets_dir.join("404.html").exists(); |
| 511 | Ok(Built { |
| 512 | worker: json!({ |
| 513 | "compatibilityDate": "2026-09-26", |
| 514 | "compatibilityFlags": [], |
| 515 | "vars": {}, |
| 516 | "notFoundHandling": if spa { "single-page-application" } else { "404-page" }, |
| 517 | }), |
| 518 | assets_dir: Some(assets_dir), |
| 519 | warnings, |
| 520 | }) |
| 521 | } |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | /// Where the project lives in the checkout: its root directory. |
| 526 | fn project_dir() -> PathBuf { |
| 527 | let root = std::env::var("ROOT_DIR").unwrap_or_default(); |
| 528 | let root = root.trim_matches('/'); |
| 529 | if root.is_empty() || root.split('/').any(|part| part == "..") { |
| 530 | PathBuf::from(WORKDIR) |
| 531 | } else { |
| 532 | Path::new(WORKDIR).join(root) |
| 533 | } |
| 534 | } |
| 535 | |
| 536 | fn check_out(secrets: &[String]) -> Result<()> { |
| 537 | let remote = env("GIT_REMOTE")?; |
| 538 | let commit = env("GIT_COMMIT")?; |
| 539 | let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?); |
| 540 | std::fs::create_dir_all("/work")?; |
| 541 | let cloned = git(Path::new("/work"), &["-c", &auth, "clone", "--quiet", &remote, WORKDIR]).and_then(|_| { |
| 542 | git( |
| 543 | Path::new(WORKDIR), |
| 544 | &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit], |
| 545 | ) |
| 546 | }); |
| 547 | if let Err(error) = cloned { |
| 548 | bail!("{}", redact(&format!("{error:#}"), secrets)); |
| 549 | } |
| 550 | Ok(()) |
| 551 | } |
| 552 | |
| 553 | fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> { |
| 554 | check_out(secrets).context("the commit could not be checked out")?; |
| 555 | // The repository's variables and secrets for deploy builds. |
| 556 | for source in ["BUILD_ENV", "BUILD_SECRETS"] { |
| 557 | if let Ok(vars) = std::env::var(source) |
| 558 | && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars) |
| 559 | { |
| 560 | for (name, value) in vars { |
| 561 | if let Some(value) = value.as_str() { |
| 562 | // SAFETY: single-threaded; set before any command runs. |
| 563 | unsafe { std::env::set_var(name, value) }; |
| 564 | } |
| 565 | } |
| 566 | } |
| 567 | } |
| 568 | let built = build(log, secrets)?; |
| 569 | let mut finish = json!({ |
| 570 | "worker": built.worker, |
| 571 | "warnings": built.warnings, |
| 572 | }); |
| 573 | if let Some(dir) = &built.assets_dir { |
| 574 | let skip_project = *dir == project_dir(); |
| 575 | let mut assets = Vec::new(); |
| 576 | collect(dir, dir, skip_project, &mut assets)?; |
| 577 | if assets.is_empty() { |
| 578 | bail!("the site to serve is empty"); |
| 579 | } |
| 580 | log.line(&format!("Uploading {} files.", assets.len())); |
| 581 | for special in ["_headers", "_redirects"] { |
| 582 | if let Ok(text) = std::fs::read_to_string(dir.join(special)) { |
| 583 | finish["worker"][special] = text.into(); |
| 584 | } |
| 585 | } |
| 586 | let manifest: BTreeMap<&str, Value> = assets |
| 587 | .iter() |
| 588 | .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size }))) |
| 589 | .collect(); |
| 590 | let answer = reporter.send("session", json!({ "manifest": manifest }))?; |
| 591 | if answer["ok"] == false { |
| 592 | bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload")); |
| 593 | } |
| 594 | let session: UploadSession = |
| 595 | serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?; |
| 596 | let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect(); |
| 597 | let mut completion = session.jwt.clone(); |
| 598 | for bucket in &session.buckets { |
| 599 | if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? { |
| 600 | completion = jwt; |
| 601 | } |
| 602 | } |
| 603 | finish["completionJwt"] = completion.into(); |
| 604 | } |
| 605 | Ok(finish) |
| 606 | } |
| 607 | |
| 608 | pub fn main() -> i32 { |
| 609 | let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) { |
| 610 | (Ok(api), Ok(id), Ok(token)) => Reporter { |
| 611 | base: format!("{api}/deployments/jobs/{id}"), |
| 612 | token, |
| 613 | }, |
| 614 | _ => { |
| 615 | eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set"); |
| 616 | return 2; |
| 617 | } |
| 618 | }; |
| 619 | let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"] |
| 620 | .iter() |
| 621 | .filter_map(|name| std::env::var(name).ok()) |
| 622 | .filter(|secret| !secret.is_empty()) |
| 623 | .collect(); |
| 624 | // The repository's build secrets never appear in the log. |
| 625 | if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) { |
| 626 | secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned)); |
| 627 | } |
| 628 | if let Err(error) = reporter.send("started", json!({})) { |
| 629 | eprintln!("g1t-runner: {error:#}"); |
| 630 | return 1; |
| 631 | } |
| 632 | let started = Instant::now(); |
| 633 | let mut log = Log::default(); |
| 634 | let outcome = deploy(&reporter, &mut log, &secrets); |
| 635 | let seconds = started.elapsed().as_secs(); |
| 636 | let sent = match outcome { |
| 637 | Ok(mut finish) => { |
| 638 | finish["log"] = redact(&log.tail(), &secrets).into(); |
| 639 | finish["buildSeconds"] = seconds.into(); |
| 640 | reporter.send("finish", finish) |
| 641 | } |
| 642 | Err(error) => { |
| 643 | let message = redact(&format!("{error:#}"), &secrets); |
| 644 | log.line(&format!("The build failed: {message}")); |
| 645 | reporter.send( |
| 646 | "fail", |
| 647 | json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }), |
| 648 | ) |
| 649 | } |
| 650 | }; |
| 651 | match sent { |
| 652 | Ok(_) => 0, |
| 653 | Err(error) => { |
| 654 | eprintln!("g1t-runner: {error:#}"); |
| 655 | 1 |
| 656 | } |
| 657 | } |
| 658 | } |
| 659 | |
| 660 | #[cfg(test)] |
| 661 | mod tests { |
| 662 | use super::*; |
| 663 | |
| 664 | #[test] |
| 665 | fn jsonc_comments_and_trailing_commas_are_dropped() { |
| 666 | let text = r#"{ |
| 667 | // a comment |
| 668 | "main": "src/index.ts", /* another */ |
| 669 | "vars": { "URL": "https://x.dev//not-a-comment", }, |
| 670 | }"#; |
| 671 | let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap(); |
| 672 | assert_eq!(config.main.as_deref(), Some("src/index.ts")); |
| 673 | assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment"); |
| 674 | } |
| 675 | |
| 676 | #[test] |
| 677 | fn unsupported_bindings_are_noticed() { |
| 678 | let config: WranglerConfig = |
| 679 | serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap(); |
| 680 | assert!(config.rest.contains_key("d1_databases")); |
| 681 | } |
| 682 | |
| 683 | #[test] |
| 684 | fn files_are_typed_by_extension() { |
| 685 | assert_eq!(content_type("/index.HTML"), "text/html"); |
| 686 | assert_eq!(content_type("/a/b.woff2"), "font/woff2"); |
| 687 | assert_eq!(content_type("/LICENSE"), "application/octet-stream"); |
| 688 | } |
| 689 | } |