Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
2,791 linesCodeBlameRaw

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 nothing1//! 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 nothing12//!
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 nothing39//! - 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 nothing47//! 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-7nm8r957//!
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 nothing66
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.67use std::collections::{BTreeMap, BTreeSet};
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing68use std::path::{Path, PathBuf};
69use std::time::Instant;
70
71use anyhow::{Context, Result, bail};
72use base64::Engine;
73use base64::engine::general_purpose::STANDARD;
Merge branch 'worktree-agent-ae1b1f625cfdcab1b'74use regex::Regex;
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing75use serde::{Deserialize, Serialize};
76use serde_json::{Value, json};
77use sha2::{Digest, Sha256};
78
79use crate::checks::{redact, run_command};
80use 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.82mod container;
83mod frameworks;
Merge branch 'worktree-agent-ae1b1f625cfdcab1b'84mod 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
86use container::BuildAs;
87use frameworks::{Adapter, Framework};
Merge branch 'worktree-agent-ae1b1f625cfdcab1b'88use 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-7nm8r990/// 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 nothing91const BUNDLE_DIR: &str = "/work/g1t-bundle";
92/// Cloudflare's limits on a Worker's static assets.
93const MAX_FILES: usize = 20_000;
94const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;
95/// How much of the build's output is kept for the deployment's log.
96const MAX_LOG_CHARS: usize = 20_000;
97/// Directories a static build usually writes to, in the order they are tried.
98const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"];
99/// Bindings a Workers project may declare that are not provisioned yet.
100const 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
113struct Reporter {
114 base: String,
115 token: String,
116}
117
118impl 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 nothing132 }
133}
134
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9135/// 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 nothing136#[derive(Default)]
137struct Log {
138 text: String,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9139 /// 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 nothing141}
142
143impl 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-7nm8r9147 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 nothing150 }
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.
163fn 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)]
177struct 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)]
190struct 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.
198fn 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
263fn 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.
282fn 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
299fn 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.
307struct Asset {
308 path: String,
309 hash: String,
310 size: u64,
311 file: PathBuf,
312}
313
314fn 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.
345fn 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.
359fn 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
365fn 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 nothing366 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 nothing377 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 nothing379 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)]
410struct 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.
420fn 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")]
456struct 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-7nm8r9463fn 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 nothing464 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-7nm8r9470 collect_files(bundle, &mut files)?;
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing471 for file in files {
472 let name = file
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9473 .strip_prefix(bundle)?
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing474 .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
502fn 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.
517fn 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 nothing528 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 nothing531 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.
550struct 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 nothing557 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 nothing563}
564
565/// What a project without a Workers config is: a site it built, or its own
566/// files served as they are.
567fn 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-7nm8r9571/// 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.
573fn 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-7nm8r9577 }
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-7nm8r9582}
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.
588fn 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.
606fn 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`.
622fn 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-7nm8r9630/// 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.
632fn 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-7nm8r9648#[derive(Debug, PartialEq, Eq)]
649pub(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-7nm8r9652 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-7nm8r9656 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)]
682pub(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
693impl 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-7nm8r9718}
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'721const 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.
727fn 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-7nm8r9761pub(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.791pub(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.
899fn 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.
917fn 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.
944fn 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-7nm8r9945 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-7nm8r9959 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-7nm8r9961 }
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-7nm8r9965 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-7nm8r9974 });
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-7nm8r9979 .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-7nm8r91011 // `.` 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.
1021const 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).
1026fn 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.
1059fn 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`).
1066const 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`.
1071fn 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.
1077fn 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)]
1085struct 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.
1099fn 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`.
1109fn 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.
1115fn 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`).
1138fn 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(&note);
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-7nm8r91228}
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.
1232fn 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.
1238const START_SECONDS: u64 = 60;
1239
1240/// What the check of a Node.js server's Worker found.
1241#[derive(Debug, PartialEq, Eq)]
1242enum 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.
1253fn 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.
1262fn 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`.
1277fn 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.
1367fn 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.
1390fn 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.1446fn 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 nothing1447 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 nothing1502 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 nothing1571 }
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 nothing1573 }
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 nothing1592 }
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 nothing1594 }
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 nothing1596 }
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 nothing1612}
1613
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91614/// Whether the build checks out its own commit (a sandbox, given
1615/// `GIT_REMOTE`) rather than building the checkout it runs in.
1616fn 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.
1622fn checkout_dir() -> PathBuf {
1623 if clones() {
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1624 PathBuf::from(WORKDIR)
1625 } else {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91626 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.
1632fn 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.
1642fn 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 nothing1648 }
1649}
1650
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91651/// Where the project lives: `ROOT_DIR` in the checkout.
1652fn 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 nothing1656fn 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 sidebar1661 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 nothing1668 if let Err(error) = cloned {
1669 bail!("{}", redact(&format!("{error:#}"), secrets));
1670 }
1671 Ok(())
1672}
1673
1674fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91675 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 nothing1684 // 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 nothing1698 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 nothing1704 });
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-7nm8r91708 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 nothing1711 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 nothing1714 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-7nm8r91745/// A sandbox's build (`MODE=deploy`).
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1746pub fn main() -> i32 {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91747 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.
1753pub 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
1761const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page.
1762
1763Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds),
1764G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR,
Merge branch 'worktree-agent-ae1b1f625cfdcab1b'1765BUILD_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.1766BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. A container
1767is built with the docker on PATH.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91768
1769g1t-runner detect prints what the build would find, as key=value lines.";
1770
1771fn run(step_log: bool) -> i32 {
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1772 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-7nm8r91774 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 nothing1775 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-7nm8r91796 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 nothing1797 let outcome = deploy(&reporter, &mut log, &secrets);
1798 let seconds = started.elapsed().as_secs();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91799 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-7nm8r91804 }
1805 let (sent, failed) = match outcome {
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1806 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-7nm8r91809 (reporter.send("finish", finish), false)
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1810 }
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-7nm8r91814 let sent = reporter.send(
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1815 "fail",
1816 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91817 );
1818 (sent, true)
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1819 }
1820 };
1821 match sent {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91822 // 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 nothing1829 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-7nm8r91837/// `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-7nm8r91843pub 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-7nm8r91853 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-7nm8r91867 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-7nm8r91872 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-7nm8r91884fn 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-7nm8r91887 "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-7nm8r91890 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-7nm8r91905}
1906
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1907#[cfg(test)]
1908mod 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 nothing1962 #[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]
2005crons = [\"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 nothing2014 }
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-7nm8r92030
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-7nm8r92058 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-7nm8r92074 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-7nm8r92082 // 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-7nm8r92102 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(&nothing, 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(&nothing, 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-7nm8r92683 }
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-7nm8r92771 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-7nm8r92790 }
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing2791}

This file's history is long; its oldest lines are credited to the oldest commit read.