Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| README says what is working today; the free allowance says what it covers | 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. | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 31 | //! |
| 32 | //! From a workflow step, `g1t-runner deploy` runs the same build in the | |
| 33 | //! job's own checkout: without `GIT_REMOTE` nothing is cloned, and the | |
| 34 | //! project is the current directory (`ROOT_DIR` below it). The build's id | |
| 35 | //! and token come from `POST /repos/{owner}/{repo}/deployments/builds`, | |
| 36 | //! which the `g1t/deploy` action calls (actions/deploy). Its output goes | |
| 37 | //! to the step's log as well as the deployment's. `g1t-runner detect` | |
| 38 | //! says what the build would find, as `key=value` lines for | |
| 39 | //! `$GITHUB_OUTPUT`, without building anything. | |
| README says what is working today; the free allowance says what it covers | 40 | |
| 41 | use std::collections::BTreeMap; | |
| 42 | use std::path::{Path, PathBuf}; | |
| 43 | use std::time::Instant; | |
| 44 | ||
| 45 | use anyhow::{Context, Result, bail}; | |
| 46 | use base64::Engine; | |
| 47 | use base64::engine::general_purpose::STANDARD; | |
| 48 | use serde::{Deserialize, Serialize}; | |
| 49 | use serde_json::{Value, json}; | |
| 50 | use sha2::{Digest, Sha256}; | |
| 51 | ||
| 52 | use crate::checks::{redact, run_command}; | |
| 53 | use crate::{WORKDIR, auth_option, env, git}; | |
| 54 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 55 | /// Where `wrangler deploy --dry-run` writes the bundle in a sandbox. |
| README says what is working today; the free allowance says what it covers | 56 | const BUNDLE_DIR: &str = "/work/g1t-bundle"; |
| 57 | /// Cloudflare's limits on a Worker's static assets. | |
| 58 | const MAX_FILES: usize = 20_000; | |
| 59 | const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024; | |
| 60 | /// How much of the build's output is kept for the deployment's log. | |
| 61 | const MAX_LOG_CHARS: usize = 20_000; | |
| 62 | /// Directories a static build usually writes to, in the order they are tried. | |
| 63 | const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"]; | |
| 64 | /// Bindings a Workers project may declare that are not provisioned yet. | |
| 65 | const UNSUPPORTED_BINDINGS: [&str; 10] = [ | |
| 66 | "kv_namespaces", | |
| 67 | "d1_databases", | |
| 68 | "r2_buckets", | |
| 69 | "durable_objects", | |
| 70 | "services", | |
| 71 | "queues", | |
| 72 | "vectorize", | |
| 73 | "hyperdrive", | |
| 74 | "ai", | |
| 75 | "workflows", | |
| 76 | ]; | |
| 77 | ||
| 78 | struct Reporter { | |
| 79 | base: String, | |
| 80 | token: String, | |
| 81 | } | |
| 82 | ||
| 83 | impl Reporter { | |
| 84 | fn send(&self, step: &str, mut body: Value) -> Result<Value> { | |
| 85 | body["token"] = self.token.clone().into(); | |
| 86 | let response = ureq::post(&format!("{}/{step}", self.base)) | |
| 87 | .send_json(body) | |
| 88 | .with_context(|| format!("could not report `{step}` to g1t"))?; | |
| 89 | Ok(response.into_json().unwrap_or(Value::Null)) | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 93 | /// The build's log, kept to its end; also printed, in a workflow step. |
| README says what is working today; the free allowance says what it covers | 94 | #[derive(Default)] |
| 95 | struct Log { | |
| 96 | text: String, | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 97 | /// Each line to standard output as well, redacted. |
| 98 | echo: Option<Vec<String>>, | |
| README says what is working today; the free allowance says what it covers | 99 | } |
| 100 | ||
| 101 | impl Log { | |
| 102 | fn line(&mut self, line: &str) { | |
| 103 | self.text.push_str(line); | |
| 104 | self.text.push('\n'); | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 105 | if let Some(secrets) = &self.echo { |
| 106 | println!("{}", redact(line, secrets)); | |
| 107 | } | |
| README says what is working today; the free allowance says what it covers | 108 | } |
| 109 | ||
| 110 | fn tail(&self) -> String { | |
| 111 | let length = self.text.chars().count(); | |
| 112 | if length <= MAX_LOG_CHARS { | |
| 113 | return self.text.clone(); | |
| 114 | } | |
| 115 | let kept: String = self.text.chars().skip(length - MAX_LOG_CHARS).collect(); | |
| 116 | format!("… (earlier output not shown)\n{kept}") | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | /// Runs a command in the checkout, logging it; fails if it fails. | |
| 121 | fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> { | |
| 122 | log.line(&format!("$ {command}")); | |
| 123 | let result = run_command(command, &project_dir(), secrets); | |
| 124 | if !result.output_text().is_empty() { | |
| 125 | log.line(result.output_text()); | |
| 126 | } | |
| 127 | if !result.passed { | |
| 128 | bail!("`{command}` failed"); | |
| 129 | } | |
| 130 | Ok(()) | |
| 131 | } | |
| 132 | ||
| 133 | /// A Workers project's settings, from whichever config file it has. | |
| 134 | #[derive(Debug, Default, Deserialize)] | |
| 135 | struct WranglerConfig { | |
| 136 | main: Option<String>, | |
| 137 | compatibility_date: Option<String>, | |
| 138 | #[serde(default)] | |
| 139 | compatibility_flags: Vec<String>, | |
| 140 | assets: Option<AssetsConfig>, | |
| 141 | #[serde(default)] | |
| 142 | vars: BTreeMap<String, Value>, | |
| 143 | #[serde(flatten)] | |
| 144 | rest: BTreeMap<String, Value>, | |
| 145 | } | |
| 146 | ||
| 147 | #[derive(Debug, Default, Deserialize)] | |
| 148 | struct AssetsConfig { | |
| 149 | directory: Option<String>, | |
| 150 | binding: Option<String>, | |
| 151 | html_handling: Option<String>, | |
| 152 | not_found_handling: Option<String>, | |
| 153 | } | |
| 154 | ||
| 155 | /// JSON with comments and trailing commas, as `wrangler.jsonc` allows. | |
| 156 | fn strip_jsonc(text: &str) -> String { | |
| 157 | let mut out = String::with_capacity(text.len()); | |
| 158 | let mut chars = text.chars().peekable(); | |
| 159 | let mut in_string = false; | |
| 160 | while let Some(c) = chars.next() { | |
| 161 | if in_string { | |
| 162 | out.push(c); | |
| 163 | if c == '\\' { | |
| 164 | if let Some(next) = chars.next() { | |
| 165 | out.push(next); | |
| 166 | } | |
| 167 | } else if c == '"' { | |
| 168 | in_string = false; | |
| 169 | } | |
| 170 | continue; | |
| 171 | } | |
| 172 | match (c, chars.peek()) { | |
| 173 | ('"', _) => { | |
| 174 | in_string = true; | |
| 175 | out.push(c); | |
| 176 | } | |
| 177 | ('/', Some('/')) => { | |
| 178 | for c in chars.by_ref() { | |
| 179 | if c == '\n' { | |
| 180 | out.push('\n'); | |
| 181 | break; | |
| 182 | } | |
| 183 | } | |
| 184 | } | |
| 185 | ('/', Some('*')) => { | |
| 186 | chars.next(); | |
| 187 | let mut last = ' '; | |
| 188 | for c in chars.by_ref() { | |
| 189 | if last == '*' && c == '/' { | |
| 190 | break; | |
| 191 | } | |
| 192 | last = c; | |
| 193 | } | |
| 194 | } | |
| 195 | _ => out.push(c), | |
| 196 | } | |
| 197 | } | |
| 198 | // Trailing commas before a closing bracket. | |
| 199 | let mut cleaned = String::with_capacity(out.len()); | |
| 200 | let chars: Vec<char> = out.chars().collect(); | |
| 201 | let mut in_string = false; | |
| 202 | let mut i = 0; | |
| 203 | while i < chars.len() { | |
| 204 | let c = chars[i]; | |
| 205 | if c == '"' && (i == 0 || chars[i - 1] != '\\') { | |
| 206 | in_string = !in_string; | |
| 207 | } | |
| 208 | if c == ',' && !in_string { | |
| 209 | let next = chars[i + 1..].iter().find(|c| !c.is_whitespace()); | |
| 210 | if matches!(next, Some('}') | Some(']')) { | |
| 211 | i += 1; | |
| 212 | continue; | |
| 213 | } | |
| 214 | } | |
| 215 | cleaned.push(c); | |
| 216 | i += 1; | |
| 217 | } | |
| 218 | cleaned | |
| 219 | } | |
| 220 | ||
| 221 | fn read_wrangler(dir: &Path) -> Result<Option<WranglerConfig>> { | |
| 222 | for name in ["wrangler.jsonc", "wrangler.json"] { | |
| 223 | let path = dir.join(name); | |
| 224 | if path.exists() { | |
| 225 | let text = std::fs::read_to_string(&path)?; | |
| 226 | return Ok(Some( | |
| 227 | serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {name}"))?, | |
| 228 | )); | |
| 229 | } | |
| 230 | } | |
| 231 | let path = dir.join("wrangler.toml"); | |
| 232 | if path.exists() { | |
| 233 | let text = std::fs::read_to_string(&path)?; | |
| 234 | return Ok(Some(toml::from_str(&text).context("could not read wrangler.toml")?)); | |
| 235 | } | |
| 236 | Ok(None) | |
| 237 | } | |
| 238 | ||
| 239 | /// How to install the project's dependencies, judged by its lockfile. | |
| 240 | fn install_command(dir: &Path) -> Option<&'static str> { | |
| 241 | if !dir.join("package.json").exists() { | |
| 242 | return None; | |
| 243 | } | |
| 244 | Some(if dir.join("pnpm-lock.yaml").exists() { | |
| 245 | "corepack enable && pnpm install --frozen-lockfile" | |
| 246 | } else if dir.join("yarn.lock").exists() { | |
| 247 | "corepack enable && yarn install" | |
| 248 | } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() { | |
| 249 | "npx --yes bun install" | |
| 250 | } else if dir.join("package-lock.json").exists() { | |
| 251 | "npm ci" | |
| 252 | } else { | |
| 253 | "npm install" | |
| 254 | }) | |
| 255 | } | |
| 256 | ||
| 257 | fn has_build_script(dir: &Path) -> bool { | |
| 258 | std::fs::read_to_string(dir.join("package.json")) | |
| 259 | .ok() | |
| 260 | .and_then(|text| serde_json::from_str::<Value>(&text).ok()) | |
| 261 | .is_some_and(|package| package["scripts"]["build"].is_string()) | |
| 262 | } | |
| 263 | ||
| 264 | /// One file of the site, as Cloudflare's asset upload names it. | |
| 265 | struct Asset { | |
| 266 | path: String, | |
| 267 | hash: String, | |
| 268 | size: u64, | |
| 269 | file: PathBuf, | |
| 270 | } | |
| 271 | ||
| 272 | fn content_type(path: &str) -> &'static str { | |
| 273 | let extension = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); | |
| 274 | match extension.as_str() { | |
| 275 | "html" | "htm" => "text/html", | |
| 276 | "css" => "text/css", | |
| 277 | "js" | "mjs" => "application/javascript", | |
| 278 | "json" | "map" => "application/json", | |
| 279 | "svg" => "image/svg+xml", | |
| 280 | "png" => "image/png", | |
| 281 | "jpg" | "jpeg" => "image/jpeg", | |
| 282 | "gif" => "image/gif", | |
| 283 | "webp" => "image/webp", | |
| 284 | "avif" => "image/avif", | |
| 285 | "ico" => "image/x-icon", | |
| 286 | "woff" => "font/woff", | |
| 287 | "woff2" => "font/woff2", | |
| 288 | "ttf" => "font/ttf", | |
| 289 | "txt" => "text/plain", | |
| 290 | "xml" => "application/xml", | |
| 291 | "wasm" => "application/wasm", | |
| 292 | "pdf" => "application/pdf", | |
| 293 | "mp4" => "video/mp4", | |
| 294 | "webm" => "video/webm", | |
| 295 | _ => "application/octet-stream", | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | /// Every file under `root`, but for what never belongs in a site. | |
| 300 | fn collect(root: &Path, dir: &Path, skip_project: bool, out: &mut Vec<Asset>) -> Result<()> { | |
| 301 | for entry in std::fs::read_dir(dir)? { | |
| 302 | let entry = entry?; | |
| 303 | let name = entry.file_name().to_string_lossy().into_owned(); | |
| 304 | let path = entry.path(); | |
| 305 | let kind = entry.file_type()?; | |
| 306 | if name == ".git" || (skip_project && (name == "node_modules" || name.starts_with(".g1t"))) { | |
| 307 | continue; | |
| 308 | } | |
| 309 | if kind.is_dir() { | |
| 310 | collect(root, &path, skip_project, out)?; | |
| 311 | continue; | |
| 312 | } | |
| 313 | if !kind.is_file() || name == "_headers" || name == "_redirects" { | |
| 314 | continue; | |
| 315 | } | |
| 316 | let size = entry.metadata()?.len(); | |
| 317 | let relative = path | |
| 318 | .strip_prefix(root)? | |
| 319 | .to_string_lossy() | |
| 320 | .replace('\\', "/"); | |
| 321 | if size > MAX_FILE_BYTES { | |
| 322 | bail!("{relative} is larger than Cloudflare's 25 MiB limit for one file"); | |
| 323 | } | |
| 324 | let bytes = std::fs::read(&path)?; | |
| 325 | let digest = hex::encode(Sha256::digest(&bytes)); | |
| 326 | out.push(Asset { | |
| 327 | path: format!("/{relative}"), | |
| 328 | hash: digest[..32].to_owned(), | |
| 329 | size, | |
| 330 | file: path, | |
| 331 | }); | |
| 332 | if out.len() > MAX_FILES { | |
| 333 | bail!("the site has more than {MAX_FILES} files, Cloudflare's limit"); | |
| 334 | } | |
| 335 | } | |
| 336 | Ok(()) | |
| 337 | } | |
| 338 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 339 | /// The upload, as the API sends it: `snake_case`, or `uploadUrl` from |
| 340 | /// before its bodies were. | |
| README says what is working today; the free allowance says what it covers | 341 | #[derive(Deserialize)] |
| 342 | struct UploadSession { | |
| 343 | jwt: String, | |
| 344 | #[serde(default)] | |
| 345 | buckets: Vec<Vec<String>>, | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 346 | #[serde(alias = "uploadUrl")] |
| README says what is working today; the free allowance says what it covers | 347 | upload_url: String, |
| 348 | } | |
| 349 | ||
| 350 | /// Sends one bucket of files with the upload's key. The last bucket's | |
| 351 | /// answer carries the key that completes the upload. | |
| 352 | fn upload_bucket(session: &UploadSession, bucket: &[String], by_hash: &BTreeMap<&str, &Asset>) -> Result<Option<String>> { | |
| 353 | let boundary = format!("g1t-{}", hex::encode(Sha256::digest(bucket.join(",").as_bytes()))[..24].to_owned()); | |
| 354 | let mut body: Vec<u8> = Vec::new(); | |
| 355 | for hash in bucket { | |
| 356 | let asset = by_hash | |
| 357 | .get(hash.as_str()) | |
| 358 | .with_context(|| format!("Cloudflare asked for a file this build does not have ({hash})"))?; | |
| 359 | let bytes = std::fs::read(&asset.file)?; | |
| 360 | body.extend_from_slice( | |
| 361 | format!( | |
| 362 | "--{boundary}\r\nContent-Disposition: form-data; name=\"{hash}\"; filename=\"{hash}\"\r\nContent-Type: {}\r\n\r\n", | |
| 363 | content_type(&asset.path) | |
| 364 | ) | |
| 365 | .as_bytes(), | |
| 366 | ); | |
| 367 | body.extend_from_slice(STANDARD.encode(bytes).as_bytes()); | |
| 368 | body.extend_from_slice(b"\r\n"); | |
| 369 | } | |
| 370 | body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); | |
| 371 | let response = ureq::post(&session.upload_url) | |
| 372 | .set("authorization", &format!("Bearer {}", session.jwt)) | |
| 373 | .set("content-type", &format!("multipart/form-data; boundary={boundary}")) | |
| 374 | .send_bytes(&body); | |
| 375 | let response = match response { | |
| 376 | Ok(response) => response, | |
| 377 | Err(ureq::Error::Status(code, response)) => { | |
| 378 | bail!("Cloudflare refused the upload ({code}): {}", response.into_string().unwrap_or_default()) | |
| 379 | } | |
| 380 | Err(error) => bail!("could not upload to Cloudflare: {error}"), | |
| 381 | }; | |
| 382 | let answer: Value = response.into_json().unwrap_or(Value::Null); | |
| 383 | Ok(answer["result"]["jwt"].as_str().map(str::to_owned)) | |
| 384 | } | |
| 385 | ||
| 386 | #[derive(Serialize)] | |
| 387 | #[serde(rename_all = "camelCase")] | |
| 388 | struct Module { | |
| 389 | name: String, | |
| 390 | content_base64: String, | |
| 391 | content_type: String, | |
| 392 | } | |
| 393 | ||
| 394 | /// The bundle `wrangler deploy --dry-run` wrote, main module first. | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 395 | fn bundle_modules(main: &str, bundle: &Path) -> Result<(String, Vec<Module>)> { |
| README says what is working today; the free allowance says what it covers | 396 | let stem = Path::new(main) |
| 397 | .file_stem() | |
| 398 | .map(|stem| stem.to_string_lossy().into_owned()) | |
| 399 | .unwrap_or_else(|| "index".to_owned()); | |
| 400 | let mut modules = Vec::new(); | |
| 401 | let mut files = Vec::new(); | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 402 | collect_files(bundle, &mut files)?; |
| README says what is working today; the free allowance says what it covers | 403 | for file in files { |
| 404 | let name = file | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 405 | .strip_prefix(bundle)? |
| README says what is working today; the free allowance says what it covers | 406 | .to_string_lossy() |
| 407 | .replace('\\', "/"); | |
| 408 | let kind = match name.rsplit('.').next().unwrap_or("") { | |
| 409 | "js" | "mjs" => "application/javascript+module", | |
| 410 | "wasm" => "application/wasm", | |
| 411 | "map" | "md" => continue, | |
| 412 | _ => "text/plain", | |
| 413 | }; | |
| 414 | modules.push(Module { | |
| 415 | content_base64: STANDARD.encode(std::fs::read(&file)?), | |
| 416 | content_type: kind.to_owned(), | |
| 417 | name, | |
| 418 | }); | |
| 419 | } | |
| 420 | let main_name = modules | |
| 421 | .iter() | |
| 422 | .map(|module| module.name.clone()) | |
| 423 | .find(|name| *name == format!("{stem}.js") || *name == format!("{stem}.mjs")) | |
| 424 | .or_else(|| { | |
| 425 | modules | |
| 426 | .iter() | |
| 427 | .find(|module| module.content_type == "application/javascript+module") | |
| 428 | .map(|module| module.name.clone()) | |
| 429 | }) | |
| 430 | .context("wrangler wrote no JavaScript module")?; | |
| 431 | Ok((main_name, modules)) | |
| 432 | } | |
| 433 | ||
| 434 | fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> { | |
| 435 | for entry in std::fs::read_dir(dir)? { | |
| 436 | let entry = entry?; | |
| 437 | if entry.file_type()?.is_dir() { | |
| 438 | collect_files(&entry.path(), out)?; | |
| 439 | } else { | |
| 440 | out.push(entry.path()); | |
| 441 | } | |
| 442 | } | |
| 443 | Ok(()) | |
| 444 | } | |
| 445 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 446 | /// What a Workers config asks for that g1t.page does not provision, said |
| 447 | /// as the deployment's warnings: bindings it runs without, and scheduled | |
| 448 | /// (`triggers.crons`) runs that never fire. | |
| 449 | fn config_warnings(config: &WranglerConfig) -> Vec<String> { | |
| 450 | let mut warnings = Vec::new(); | |
| 451 | for binding in UNSUPPORTED_BINDINGS { | |
| 452 | if config.rest.get(binding).is_some_and(|value| !value.is_null()) { | |
| 453 | warnings.push(format!("`{binding}` is not provisioned on g1t.page yet, so the app runs without it.")); | |
| 454 | } | |
| 455 | } | |
| 456 | let crons = config | |
| 457 | .rest | |
| 458 | .get("triggers") | |
| 459 | .and_then(|triggers| triggers.get("crons")) | |
| 460 | .and_then(Value::as_array) | |
| 461 | .map_or(0, |crons| crons.iter().filter(|cron| cron.as_str().is_some_and(|c| !c.trim().is_empty())).count()); | |
| 462 | if crons > 0 { | |
| 463 | let schedules = if crons == 1 { "schedule" } else { "schedules" }; | |
| 464 | warnings.push(format!( | |
| 465 | "`triggers.crons` ({crons} {schedules}) is not set up on g1t.page yet, so the Worker's `scheduled` handler never runs." | |
| 466 | )); | |
| 467 | } | |
| 468 | warnings | |
| 469 | } | |
| 470 | ||
| README says what is working today; the free allowance says what it covers | 471 | /// What was built: the Worker's code and settings, and where its site is. |
| 472 | struct Built { | |
| 473 | worker: Value, | |
| 474 | assets_dir: Option<PathBuf>, | |
| 475 | warnings: Vec<String>, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 476 | /// What kind of project it was found to be, for its settings page: |
| 477 | /// `workers`, `static` (a build's output) or `html` (the files as they are). | |
| 478 | detected: &'static str, | |
| README says what is working today; the free allowance says what it covers | 479 | } |
| 480 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 481 | /// What a project without a Workers config is: a site it built, or its own |
| 482 | /// files served as they are. | |
| 483 | fn static_kind(project: &Path, assets_dir: &Path, built: bool) -> &'static str { | |
| 484 | if !built && assets_dir == project { "html" } else { "static" } | |
| 485 | } | |
| 486 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 487 | /// The command that bundles a Workers project, without deploying it, |
| 488 | /// into `bundle` when given. | |
| 489 | fn wrangler_build(bundle: Option<&Path>) -> String { | |
| 490 | match bundle { | |
| 491 | Some(bundle) => format!("npx --yes wrangler@4 deploy --dry-run --outdir {}", bundle.display()), | |
| 492 | None => "npx --yes wrangler@4 deploy --dry-run".to_owned(), | |
| 493 | } | |
| 494 | } | |
| 495 | ||
| 496 | /// The first of the usual output directories a static build wrote (or the | |
| 497 | /// project's own `index.html`), relative to the project: `.` for itself. | |
| 498 | fn find_output(dir: &Path) -> Option<String> { | |
| 499 | OUTPUT_DIRS | |
| 500 | .iter() | |
| 501 | .find(|name| { | |
| 502 | let path = dir.join(name); | |
| 503 | path.join("index.html").exists() || (path.is_dir() && **name != "public") | |
| 504 | }) | |
| 505 | .map(|name| (*name).to_owned()) | |
| 506 | .or_else(|| dir.join("index.html").exists().then(|| ".".to_owned())) | |
| 507 | } | |
| 508 | ||
| 509 | /// What a build of the project at `dir` does, found before anything runs: | |
| 510 | /// what it is, how its dependencies are installed, what builds it, and | |
| 511 | /// where what it serves is. For a site with a build of its own, the output | |
| 512 | /// directory may only exist once it has run; then `output_dir` is `None` | |
| 513 | /// unless one was chosen. | |
| 514 | #[derive(Debug, PartialEq, Eq)] | |
| 515 | pub(crate) struct Detected { | |
| 516 | /// `workers`, `static`, `html`, or `none` when there is nothing to serve. | |
| 517 | pub kind: &'static str, | |
| 518 | pub install: Option<&'static str>, | |
| 519 | pub build: Option<String>, | |
| 520 | pub output_dir: Option<String>, | |
| 521 | } | |
| 522 | ||
| 523 | pub(crate) fn detect(dir: &Path, custom_build: Option<&str>, chosen_output: Option<&str>) -> Result<Detected> { | |
| 524 | let custom_build = custom_build.map(str::trim).filter(|c| !c.is_empty()); | |
| 525 | let chosen_output = chosen_output.map(|d| d.trim().trim_matches('/')).filter(|d| !d.is_empty()); | |
| 526 | let install = install_command(dir); | |
| 527 | if let Some(config) = read_wrangler(dir)? { | |
| 528 | let mut steps: Vec<String> = custom_build.map(str::to_owned).into_iter().collect(); | |
| 529 | if config.main.is_some() { | |
| 530 | steps.push(wrangler_build(None)); | |
| 531 | } | |
| 532 | return Ok(Detected { | |
| 533 | kind: "workers", | |
| 534 | install, | |
| 535 | build: (!steps.is_empty()).then(|| steps.join(" && ")), | |
| 536 | output_dir: config.assets.and_then(|assets| assets.directory), | |
| 537 | }); | |
| 538 | } | |
| 539 | let build = custom_build | |
| 540 | .map(str::to_owned) | |
| 541 | .or_else(|| has_build_script(dir).then(|| "npm run build".to_owned())); | |
| 542 | let output_dir = chosen_output.map(str::to_owned).or_else(|| find_output(dir)); | |
| 543 | // `.` is the project itself: its own files, served as they are. | |
| 544 | let kind = match (&build, output_dir.as_deref()) { | |
| 545 | (None, Some(".")) => "html", | |
| 546 | (None, None) => "none", | |
| 547 | _ => "static", | |
| 548 | }; | |
| 549 | Ok(Detected { kind, install, build, output_dir }) | |
| 550 | } | |
| 551 | ||
| README says what is working today; the free allowance says what it covers | 552 | fn build(log: &mut Log, secrets: &[String]) -> Result<Built> { |
| 553 | let project = project_dir(); | |
| 554 | let dir = project.as_path(); | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 555 | let bundle = bundle_dir(); |
| README says what is working today; the free allowance says what it covers | 556 | let config = read_wrangler(dir)?; |
| 557 | let custom_build = std::env::var("BUILD_COMMAND").ok().filter(|c| !c.trim().is_empty()); | |
| 558 | if let Some(install) = install_command(dir) { | |
| 559 | step(log, install, secrets)?; | |
| 560 | } | |
| 561 | let mut warnings = Vec::new(); | |
| 562 | match config { | |
| 563 | Some(config) => { | |
| 564 | if let Some(command) = &custom_build { | |
| 565 | step(log, command, secrets)?; | |
| 566 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 567 | warnings.extend(config_warnings(&config)); |
| README says what is working today; the free allowance says what it covers | 568 | let mut worker = json!({ |
| 569 | "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| "2026-09-26".to_owned()), | |
| 570 | "compatibilityFlags": config.compatibility_flags, | |
| 571 | "vars": config.vars, | |
| 572 | }); | |
| 573 | if let Some(main) = &config.main { | |
| 574 | // `--dry-run` runs the project's own build and bundles it, | |
| 575 | // without deploying anywhere. | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 576 | let _ = std::fs::remove_dir_all(&bundle); |
| 577 | step(log, &wrangler_build(Some(&bundle)), secrets)?; | |
| 578 | let (main_module, modules) = bundle_modules(main, &bundle)?; | |
| README says what is working today; the free allowance says what it covers | 579 | worker["mainModule"] = main_module.into(); |
| 580 | worker["modules"] = serde_json::to_value(modules)?; | |
| 581 | } | |
| 582 | let assets = config.assets.unwrap_or_default(); | |
| 583 | let assets_dir = assets.directory.as_ref().map(|directory| dir.join(directory)); | |
| 584 | worker["assetsBinding"] = assets.binding.into(); | |
| 585 | worker["htmlHandling"] = assets.html_handling.into(); | |
| 586 | worker["notFoundHandling"] = assets.not_found_handling.into(); | |
| 587 | Ok(Built { | |
| 588 | worker, | |
| 589 | assets_dir, | |
| 590 | warnings, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 591 | detected: "workers", |
| README says what is working today; the free allowance says what it covers | 592 | }) |
| 593 | } | |
| 594 | None => { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 595 | let built = custom_build.is_some() || has_build_script(dir); |
| README says what is working today; the free allowance says what it covers | 596 | if let Some(command) = &custom_build { |
| 597 | step(log, command, secrets)?; | |
| 598 | } else if has_build_script(dir) { | |
| 599 | step(log, "npm run build", secrets)?; | |
| 600 | } | |
| 601 | let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty()); | |
| 602 | let assets_dir = match chosen { | |
| 603 | Some(chosen) => { | |
| 604 | let path = dir.join(chosen.trim_matches('/')); | |
| 605 | if !path.is_dir() { | |
| 606 | bail!("the output directory `{chosen}` does not exist after the build"); | |
| 607 | } | |
| 608 | path | |
| 609 | } | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 610 | None => match find_output(dir).context( |
| 611 | "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public", | |
| 612 | )? { | |
| 613 | own if own == "." => dir.to_path_buf(), | |
| 614 | output => dir.join(output), | |
| 615 | }, | |
| README says what is working today; the free allowance says what it covers | 616 | }; |
| 617 | let spa = !assets_dir.join("404.html").exists(); | |
| 618 | Ok(Built { | |
| 619 | worker: json!({ | |
| 620 | "compatibilityDate": "2026-09-26", | |
| 621 | "compatibilityFlags": [], | |
| 622 | "vars": {}, | |
| 623 | "notFoundHandling": if spa { "single-page-application" } else { "404-page" }, | |
| 624 | }), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 625 | detected: static_kind(dir, &assets_dir, built), |
| README says what is working today; the free allowance says what it covers | 626 | assets_dir: Some(assets_dir), |
| 627 | warnings, | |
| 628 | }) | |
| 629 | } | |
| 630 | } | |
| 631 | } | |
| 632 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 633 | /// Whether the build checks out its own commit (a sandbox, given |
| 634 | /// `GIT_REMOTE`) rather than building the checkout it runs in. | |
| 635 | fn clones() -> bool { | |
| 636 | std::env::var("GIT_REMOTE").is_ok_and(|remote| !remote.trim().is_empty()) | |
| 637 | } | |
| 638 | ||
| 639 | /// The checkout the build runs in: the sandbox's clone, or, from a | |
| 640 | /// workflow step, the current directory. | |
| 641 | fn checkout_dir() -> PathBuf { | |
| 642 | if clones() { | |
| README says what is working today; the free allowance says what it covers | 643 | PathBuf::from(WORKDIR) |
| 644 | } else { | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 645 | std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) |
| 646 | } | |
| 647 | } | |
| 648 | ||
| 649 | /// Where `wrangler deploy --dry-run` writes the bundle: in a workflow | |
| 650 | /// step, a directory of the job's temporary space, never the checkout. | |
| 651 | fn bundle_dir() -> PathBuf { | |
| 652 | if clones() { | |
| 653 | return PathBuf::from(BUNDLE_DIR); | |
| 654 | } | |
| 655 | let base = std::env::var_os("RUNNER_TEMP").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); | |
| 656 | base.join(format!("g1t-bundle-{}", std::process::id())) | |
| 657 | } | |
| 658 | ||
| 659 | /// Where the project lives in the checkout `base`: its root directory, | |
| 660 | /// never above the checkout. | |
| 661 | fn project_dir_in(base: &Path, root: &str) -> PathBuf { | |
| 662 | let root = root.trim().trim_matches('/'); | |
| 663 | if root.is_empty() || root == "." || root.split('/').any(|part| part == "..") { | |
| 664 | base.to_path_buf() | |
| 665 | } else { | |
| 666 | base.join(root) | |
| README says what is working today; the free allowance says what it covers | 667 | } |
| 668 | } | |
| 669 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 670 | /// Where the project lives: `ROOT_DIR` in the checkout. |
| 671 | fn project_dir() -> PathBuf { | |
| 672 | project_dir_in(&checkout_dir(), &std::env::var("ROOT_DIR").unwrap_or_default()) | |
| 673 | } | |
| 674 | ||
| README says what is working today; the free allowance says what it covers | 675 | fn check_out(secrets: &[String]) -> Result<()> { |
| 676 | let remote = env("GIT_REMOTE")?; | |
| 677 | let commit = env("GIT_COMMIT")?; | |
| 678 | let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?); | |
| 679 | std::fs::create_dir_all("/work")?; | |
| Previews build from any branch; Agent in place of Ask AI; pin from the sidebar | 680 | let workdir = Path::new(WORKDIR); |
| 681 | // The clone is shallow and of the default branch; a preview's commit is | |
| 682 | // often on another one, so it is fetched before checking out. | |
| 683 | let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR) | |
| 684 | .and_then(|_| git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])) | |
| 685 | .and_then(|branch| crate::clone::ensure(workdir, &auth, "origin", &branch, &commit)) | |
| 686 | .and_then(|_| git(workdir, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit])); | |
| README says what is working today; the free allowance says what it covers | 687 | if let Err(error) = cloned { |
| 688 | bail!("{}", redact(&format!("{error:#}"), secrets)); | |
| 689 | } | |
| 690 | Ok(()) | |
| 691 | } | |
| 692 | ||
| 693 | fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> { | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 694 | if clones() { |
| 695 | check_out(secrets).context("the commit could not be checked out")?; | |
| 696 | } else { | |
| 697 | let dir = project_dir(); | |
| 698 | if !dir.is_dir() { | |
| 699 | bail!("the project's directory {} does not exist in this checkout", dir.display()); | |
| 700 | } | |
| 701 | log.line(&format!("Building {} as it is checked out.", dir.display())); | |
| 702 | } | |
| README says what is working today; the free allowance says what it covers | 703 | // The repository's variables and secrets for deploy builds. |
| 704 | for source in ["BUILD_ENV", "BUILD_SECRETS"] { | |
| 705 | if let Ok(vars) = std::env::var(source) | |
| 706 | && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars) | |
| 707 | { | |
| 708 | for (name, value) in vars { | |
| 709 | if let Some(value) = value.as_str() { | |
| 710 | // SAFETY: single-threaded; set before any command runs. | |
| 711 | unsafe { std::env::set_var(name, value) }; | |
| 712 | } | |
| 713 | } | |
| 714 | } | |
| 715 | } | |
| 716 | let built = build(log, secrets)?; | |
| 717 | let mut finish = json!({ | |
| 718 | "worker": built.worker, | |
| 719 | "warnings": built.warnings, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 720 | "detected": built.detected, |
| README says what is working today; the free allowance says what it covers | 721 | }); |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 722 | for warning in &built.warnings { |
| 723 | log.line(&format!("Warning: {warning}")); | |
| 724 | } | |
| README says what is working today; the free allowance says what it covers | 725 | if let Some(dir) = &built.assets_dir { |
| 726 | let skip_project = *dir == project_dir(); | |
| 727 | let mut assets = Vec::new(); | |
| 728 | collect(dir, dir, skip_project, &mut assets)?; | |
| 729 | if assets.is_empty() { | |
| 730 | bail!("the site to serve is empty"); | |
| 731 | } | |
| 732 | log.line(&format!("Uploading {} files.", assets.len())); | |
| 733 | for special in ["_headers", "_redirects"] { | |
| 734 | if let Ok(text) = std::fs::read_to_string(dir.join(special)) { | |
| 735 | finish["worker"][special] = text.into(); | |
| 736 | } | |
| 737 | } | |
| 738 | let manifest: BTreeMap<&str, Value> = assets | |
| 739 | .iter() | |
| 740 | .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size }))) | |
| 741 | .collect(); | |
| 742 | let answer = reporter.send("session", json!({ "manifest": manifest }))?; | |
| 743 | if answer["ok"] == false { | |
| 744 | bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload")); | |
| 745 | } | |
| 746 | let session: UploadSession = | |
| 747 | serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?; | |
| 748 | let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect(); | |
| 749 | let mut completion = session.jwt.clone(); | |
| 750 | for bucket in &session.buckets { | |
| 751 | if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? { | |
| 752 | completion = jwt; | |
| 753 | } | |
| 754 | } | |
| 755 | finish["completionJwt"] = completion.into(); | |
| 756 | } | |
| 757 | Ok(finish) | |
| 758 | } | |
| 759 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 760 | /// A sandbox's build (`MODE=deploy`). |
| README says what is working today; the free allowance says what it covers | 761 | pub fn main() -> i32 { |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 762 | run(false) |
| 763 | } | |
| 764 | ||
| 765 | /// `g1t-runner deploy`, from a workflow step: the build in the job's own | |
| 766 | /// checkout, its log in the step's as well. Exits 1 when the build fails, | |
| 767 | /// after reporting it, so the step fails too. | |
| 768 | pub fn cli(args: &[String]) -> i32 { | |
| 769 | if args.iter().any(|arg| arg == "--help" || arg == "-h") { | |
| 770 | println!("{CLI_HELP}"); | |
| 771 | return 0; | |
| 772 | } | |
| 773 | run(true) | |
| 774 | } | |
| 775 | ||
| 776 | const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page. | |
| 777 | ||
| 778 | Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds), | |
| 779 | G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR, | |
| 780 | BUILD_ENV and BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. | |
| 781 | ||
| 782 | g1t-runner detect prints what the build would find, as key=value lines."; | |
| 783 | ||
| 784 | fn run(step_log: bool) -> i32 { | |
| README says what is working today; the free allowance says what it covers | 785 | let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) { |
| 786 | (Ok(api), Ok(id), Ok(token)) => Reporter { | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 787 | base: format!("{}/deployments/jobs/{id}", api.trim_end_matches('/')), |
| README says what is working today; the free allowance says what it covers | 788 | token, |
| 789 | }, | |
| 790 | _ => { | |
| 791 | eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set"); | |
| 792 | return 2; | |
| 793 | } | |
| 794 | }; | |
| 795 | let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"] | |
| 796 | .iter() | |
| 797 | .filter_map(|name| std::env::var(name).ok()) | |
| 798 | .filter(|secret| !secret.is_empty()) | |
| 799 | .collect(); | |
| 800 | // The repository's build secrets never appear in the log. | |
| 801 | if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) { | |
| 802 | secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned)); | |
| 803 | } | |
| 804 | if let Err(error) = reporter.send("started", json!({})) { | |
| 805 | eprintln!("g1t-runner: {error:#}"); | |
| 806 | return 1; | |
| 807 | } | |
| 808 | let started = Instant::now(); | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 809 | let mut log = Log { echo: step_log.then(|| secrets.clone()), ..Log::default() }; |
| README says what is working today; the free allowance says what it covers | 810 | let outcome = deploy(&reporter, &mut log, &secrets); |
| 811 | let seconds = started.elapsed().as_secs(); | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 812 | if step_log { |
| 813 | let _ = std::fs::remove_dir_all(bundle_dir()); | |
| 814 | } | |
| 815 | let (sent, failed) = match outcome { | |
| README says what is working today; the free allowance says what it covers | 816 | Ok(mut finish) => { |
| 817 | finish["log"] = redact(&log.tail(), &secrets).into(); | |
| 818 | finish["buildSeconds"] = seconds.into(); | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 819 | (reporter.send("finish", finish), false) |
| README says what is working today; the free allowance says what it covers | 820 | } |
| 821 | Err(error) => { | |
| 822 | let message = redact(&format!("{error:#}"), &secrets); | |
| 823 | log.line(&format!("The build failed: {message}")); | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 824 | let sent = reporter.send( |
| README says what is working today; the free allowance says what it covers | 825 | "fail", |
| 826 | json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }), | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 827 | ); |
| 828 | (sent, true) | |
| README says what is working today; the free allowance says what it covers | 829 | } |
| 830 | }; | |
| 831 | match sent { | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 832 | // A sandbox's build has said how it ended; a step fails with its build. |
| 833 | Ok(answer) if step_log && (failed || answer["ok"] == false || answer["value"] == false) => { | |
| 834 | if !failed { | |
| 835 | eprintln!("g1t-runner: g1t did not take the build; its deployment says why"); | |
| 836 | } | |
| 837 | 1 | |
| 838 | } | |
| README says what is working today; the free allowance says what it covers | 839 | Ok(_) => 0, |
| 840 | Err(error) => { | |
| 841 | eprintln!("g1t-runner: {error:#}"); | |
| 842 | 1 | |
| 843 | } | |
| 844 | } | |
| 845 | } | |
| 846 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 847 | /// `g1t-runner detect`: what a build of the project would find, as |
| 848 | /// `key=value` lines for `$GITHUB_OUTPUT`: `kind`, `install`, `build` and | |
| 849 | /// `output-dir`. Reads `ROOT_DIR`, `BUILD_COMMAND` and `OUTPUT_DIR` as the | |
| 850 | /// build does; `--root-dir DIR` is the same as `ROOT_DIR`. | |
| 851 | pub fn detect_cli(args: &[String]) -> i32 { | |
| 852 | let mut root = std::env::var("ROOT_DIR").unwrap_or_default(); | |
| 853 | let mut rest = args.iter(); | |
| 854 | while let Some(arg) = rest.next() { | |
| 855 | match arg.as_str() { | |
| 856 | "--root-dir" => root = rest.next().cloned().unwrap_or_default(), | |
| 857 | "--help" | "-h" => { | |
| 858 | println!("g1t-runner detect [--root-dir DIR]: prints kind, install, build and output-dir as key=value lines."); | |
| 859 | return 0; | |
| 860 | } | |
| 861 | other => { | |
| 862 | eprintln!("g1t-runner detect: unknown argument {other}"); | |
| 863 | return 2; | |
| 864 | } | |
| 865 | } | |
| 866 | } | |
| 867 | let dir = project_dir_in(&checkout_dir(), &root); | |
| 868 | let custom = std::env::var("BUILD_COMMAND").ok(); | |
| 869 | let output = std::env::var("OUTPUT_DIR").ok(); | |
| 870 | match detect(&dir, custom.as_deref(), output.as_deref()) { | |
| 871 | Ok(found) => { | |
| 872 | print!("{}", output_lines(&found)); | |
| 873 | 0 | |
| 874 | } | |
| 875 | Err(error) => { | |
| 876 | eprintln!("g1t-runner detect: {error:#}"); | |
| 877 | 1 | |
| 878 | } | |
| 879 | } | |
| 880 | } | |
| 881 | ||
| 882 | /// `Detected` as `$GITHUB_OUTPUT` lines; a value on one line, empty when | |
| 883 | /// there is none. | |
| 884 | fn output_lines(found: &Detected) -> String { | |
| 885 | let one_line = |value: Option<&str>| value.unwrap_or_default().replace(['\r', '\n'], " "); | |
| 886 | format!( | |
| 887 | "kind={}\ninstall={}\nbuild={}\noutput-dir={}\n", | |
| 888 | found.kind, | |
| 889 | one_line(found.install), | |
| 890 | one_line(found.build.as_deref()), | |
| 891 | one_line(found.output_dir.as_deref()), | |
| 892 | ) | |
| 893 | } | |
| 894 | ||
| README says what is working today; the free allowance says what it covers | 895 | #[cfg(test)] |
| 896 | mod tests { | |
| 897 | use super::*; | |
| 898 | ||
| 899 | #[test] | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 900 | fn the_upload_session_is_read_in_either_spelling() { |
| 901 | for answer in [ | |
| 902 | json!({ "jwt": "j", "buckets": [["a"]], "upload_url": "https://u" }), | |
| 903 | json!({ "jwt": "j", "uploadUrl": "https://u" }), | |
| 904 | ] { | |
| 905 | let session: UploadSession = serde_json::from_value(answer).unwrap(); | |
| 906 | assert_eq!(session.upload_url, "https://u"); | |
| 907 | } | |
| 908 | } | |
| 909 | ||
| 910 | #[test] | |
| README says what is working today; the free allowance says what it covers | 911 | fn jsonc_comments_and_trailing_commas_are_dropped() { |
| 912 | let text = r#"{ | |
| 913 | // a comment | |
| 914 | "main": "src/index.ts", /* another */ | |
| 915 | "vars": { "URL": "https://x.dev//not-a-comment", }, | |
| 916 | }"#; | |
| 917 | let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap(); | |
| 918 | assert_eq!(config.main.as_deref(), Some("src/index.ts")); | |
| 919 | assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment"); | |
| 920 | } | |
| 921 | ||
| 922 | #[test] | |
| 923 | fn unsupported_bindings_are_noticed() { | |
| 924 | let config: WranglerConfig = | |
| 925 | serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap(); | |
| 926 | assert!(config.rest.contains_key("d1_databases")); | |
| 927 | } | |
| 928 | ||
| 929 | #[test] | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 930 | fn crons_g1t_does_not_run_are_warned_about() { |
| 931 | let config: WranglerConfig = | |
| 932 | serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": ["*/5 * * * *", "0 3 * * *"] }, "kv_namespaces": [{ "binding": "KV" }] }"#) | |
| 933 | .unwrap(); | |
| 934 | let warnings = config_warnings(&config); | |
| 935 | assert_eq!(warnings.len(), 2); | |
| 936 | assert!(warnings.iter().any(|w| w.contains("kv_namespaces"))); | |
| 937 | assert!(warnings.iter().any(|w| w.contains("triggers.crons") && w.contains("2 schedules"))); | |
| 938 | let none: WranglerConfig = serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": [] } }"#).unwrap(); | |
| 939 | assert!(config_warnings(&none).is_empty()); | |
| 940 | let toml: WranglerConfig = toml::from_str("main = \"a.js\" | |
| 941 | [triggers] | |
| 942 | crons = [\"0 * * * *\"] | |
| 943 | ").unwrap(); | |
| 944 | assert!(config_warnings(&toml)[0].contains("1 schedule)")); | |
| 945 | } | |
| 946 | ||
| 947 | #[test] | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 948 | fn a_site_is_plain_html_only_when_its_own_files_are_served_unbuilt() { |
| 949 | let project = Path::new("/w/site"); | |
| 950 | assert_eq!(static_kind(project, project, false), "html"); | |
| 951 | assert_eq!(static_kind(project, project, true), "static"); | |
| 952 | assert_eq!(static_kind(project, &project.join("dist"), false), "static"); | |
| 953 | } | |
| 954 | ||
| 955 | #[test] | |
| README says what is working today; the free allowance says what it covers | 956 | fn files_are_typed_by_extension() { |
| 957 | assert_eq!(content_type("/index.HTML"), "text/html"); | |
| 958 | assert_eq!(content_type("/a/b.woff2"), "font/woff2"); | |
| 959 | assert_eq!(content_type("/LICENSE"), "application/octet-stream"); | |
| 960 | } | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 961 | |
| 962 | /// A fresh directory with `files` (path, contents) in it. | |
| 963 | fn project(name: &str, files: &[(&str, &str)]) -> PathBuf { | |
| 964 | let dir = std::env::temp_dir().join(format!("g1t-detect-{name}-{}", std::process::id())); | |
| 965 | let _ = std::fs::remove_dir_all(&dir); | |
| 966 | for (path, text) in files { | |
| 967 | let path = dir.join(path); | |
| 968 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | |
| 969 | std::fs::write(path, text).unwrap(); | |
| 970 | } | |
| 971 | std::fs::create_dir_all(&dir).unwrap(); | |
| 972 | dir | |
| 973 | } | |
| 974 | ||
| 975 | #[test] | |
| 976 | fn detect_finds_a_workers_project_and_how_it_builds() { | |
| 977 | let dir = project( | |
| 978 | "workers", | |
| 979 | &[ | |
| 980 | ("wrangler.jsonc", r#"{ "main": "src/index.ts", "assets": { "directory": "./public" }, }"#), | |
| 981 | ("package.json", "{}"), | |
| 982 | ("pnpm-lock.yaml", ""), | |
| 983 | ], | |
| 984 | ); | |
| 985 | let found = detect(&dir, None, None).unwrap(); | |
| 986 | assert_eq!(found.kind, "workers"); | |
| 987 | assert_eq!(found.install, Some("corepack enable && pnpm install --frozen-lockfile")); | |
| 988 | assert_eq!(found.build.as_deref(), Some("npx --yes wrangler@4 deploy --dry-run")); | |
| 989 | assert_eq!(found.output_dir.as_deref(), Some("./public")); | |
| 990 | // The project's own build command runs first. | |
| 991 | let custom = detect(&dir, Some("npm run codegen"), None).unwrap(); | |
| 992 | assert_eq!(custom.build.as_deref(), Some("npm run codegen && npx --yes wrangler@4 deploy --dry-run")); | |
| 993 | } | |
| 994 | ||
| 995 | #[test] | |
| 996 | fn detect_finds_a_static_build_and_a_plain_site() { | |
| 997 | let built = project("static", &[("package.json", r#"{ "scripts": { "build": "vite build" } }"#), ("package-lock.json", "{}")]); | |
| 998 | let found = detect(&built, None, None).unwrap(); | |
| 999 | assert_eq!( | |
| 1000 | found, | |
| 1001 | Detected { kind: "static", install: Some("npm ci"), build: Some("npm run build".into()), output_dir: None }, | |
| 1002 | "its output is found once it has built" | |
| 1003 | ); | |
| 1004 | std::fs::create_dir_all(built.join("dist")).unwrap(); | |
| 1005 | std::fs::write(built.join("dist/index.html"), "<h1>hi</h1>").unwrap(); | |
| 1006 | assert_eq!(detect(&built, None, None).unwrap().output_dir.as_deref(), Some("dist")); | |
| 1007 | assert_eq!(detect(&built, None, Some("/site/")).unwrap().output_dir.as_deref(), Some("site")); | |
| 1008 | ||
| 1009 | let html = project("html", &[("index.html", "<h1>hi</h1>")]); | |
| 1010 | assert_eq!(detect(&html, None, None).unwrap(), Detected { kind: "html", install: None, build: None, output_dir: Some(".".into()) }); | |
| 1011 | // A `public` folder alone, without an index.html, is not a site. | |
| 1012 | let empty = project("empty", &[("public/robots.txt", ""), ("README.md", "")]); | |
| 1013 | assert_eq!(detect(&empty, None, None).unwrap().kind, "none"); | |
| 1014 | } | |
| 1015 | ||
| 1016 | #[test] | |
| 1017 | fn detect_prints_lines_for_github_output() { | |
| 1018 | let found = Detected { kind: "static", install: Some("npm ci"), build: Some("a\nb".into()), output_dir: None }; | |
| 1019 | assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n"); | |
| 1020 | } | |
| 1021 | ||
| 1022 | #[test] | |
| 1023 | fn the_root_directory_stays_inside_the_checkout() { | |
| 1024 | let base = Path::new("/w/repo"); | |
| 1025 | assert_eq!(project_dir_in(base, ""), base); | |
| 1026 | assert_eq!(project_dir_in(base, "."), base); | |
| 1027 | assert_eq!(project_dir_in(base, "/apps/site/"), base.join("apps/site")); | |
| 1028 | assert_eq!(project_dir_in(base, "../etc"), base); | |
| 1029 | assert_eq!(project_dir_in(base, "apps/../../x"), base); | |
| 1030 | } | |
| 1031 | ||
| 1032 | #[test] | |
| 1033 | fn a_step_prints_the_log_redacted() { | |
| 1034 | let mut log = Log { echo: Some(vec!["hunter22".to_owned()]), ..Log::default() }; | |
| 1035 | log.line("token hunter22 used"); | |
| 1036 | assert!(log.text.contains("hunter22"), "the deployment's log is redacted when it is sent"); | |
| 1037 | assert_eq!(wrangler_build(Some(Path::new("/tmp/b"))), "npx --yes wrangler@4 deploy --dry-run --outdir /tmp/b"); | |
| 1038 | } | |
| README says what is working today; the free allowance says what it covers | 1039 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.