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