Skip to content
2,196 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.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! Builds one commit of a repository and hands the result to Cloudflare, as
2//! a preview of a pull request or as the repository's production.
3//!
4//! The sandbox never holds a Cloudflare credential that could touch anything
5//! else. The deployments service opens an upload for exactly the files
6//! this build produced and gives back a key that can only upload those;
7//! the sandbox uploads them with it, and sends the Worker's code to the
8//! service, which puts the app in place.
9//!
10//! What gets built:
11//!
12//! - A Workers project (a `wrangler.jsonc`, `wrangler.json` or
13//! `wrangler.toml`): bundled by `wrangler deploy --dry-run`, with its
14//! static assets, compatibility settings and `vars`. Other bindings (D1,
15//! KV, R2, Durable Objects…) are not provisioned yet; the deployment says
16//! which were left out.
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.17//! - A framework g1t knows (deploy/frameworks.rs: Vite, Astro, Next.js,
18//! SvelteKit, Nuxt, Hugo, Jekyll and the rest), found by the package's
19//! dependencies and config files: built with its own command and served
20//! from the folder it writes. A Cloudflare adapter's Worker (Astro's
21//! `dist/_worker.js`, SvelteKit's `.svelte-kit/cloudflare/_worker.js`) is
22//! bundled as a Workers project's is, without a Wrangler config of its
23//! own. A framework set up to need a server fails before anything is
24//! installed, saying what to change.
25//! - A container (deploy/container.rs): a project with a `Dockerfile` (or
26//! `Containerfile`, or the path it chose) and no framework that builds a
27//! static site or a Worker, a framework set up to need a server, or a
28//! Node.js, Python, Go, Ruby or PHP server, for which g1t writes a
29//! Dockerfile and shows it in the log. The image is built with Docker
30//! and pushed to Cloudflare's registry with a push-only login the
31//! deployments service gives this build alone, for minutes (the
32//! `registry` step); the service runs it.
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor33//! - Anything else: a static site. Its `build` script runs, and the first
34//! of `dist`, `build`, `out`, `public`, `_site` or `.output/public` that
35//! exists is served, or the repository itself if it has an `index.html`.
36//!
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.37//! The project's own **Build as** (`BUILD_AS`: auto, static, workers or
38//! docker), **Dockerfile** (`DOCKERFILE`), **Build command** and **Output
39//! directory** win over what is detected.
40//!
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor41//! Configuration comes from the environment:
42//!
43//! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report.
44//! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out.
45//! - `ROOT_DIR`: where in the repository the project lives; empty for all
46//! of it. Everything below is relative to it.
47//! - `BUILD_COMMAND`, `OUTPUT_DIR`: the project's own choices, if any.
48//! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's
49//! variables and secrets for deploy builds. Both are set for the build;
50//! secrets' values are redacted from its log.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r951//!
52//! From a workflow step, `g1t-runner deploy` runs the same build in the
53//! job's own checkout: without `GIT_REMOTE` nothing is cloned, and the
54//! project is the current directory (`ROOT_DIR` below it). The build's id
55//! and token come from `POST /repos/{owner}/{repo}/deployments/builds`,
56//! which the `g1t/deploy` action calls (actions/deploy). Its output goes
57//! to the step's log as well as the deployment's. `g1t-runner detect`
58//! says what the build would find, as `key=value` lines for
59//! `$GITHUB_OUTPUT`, without building anything.
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor60
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.61use std::collections::{BTreeMap, BTreeSet};
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor62use std::path::{Path, PathBuf};
63use std::time::Instant;
64
65use anyhow::{Context, Result, bail};
66use base64::Engine;
67use base64::engine::general_purpose::STANDARD;
68use serde::{Deserialize, Serialize};
69use serde_json::{Value, json};
70use sha2::{Digest, Sha256};
71
72use crate::checks::{redact, run_command};
73use crate::{WORKDIR, auth_option, env, git};
74
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.75mod container;
76mod frameworks;
77
78use container::BuildAs;
79use frameworks::{Adapter, Framework};
80
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r981/// Where `wrangler deploy --dry-run` writes the bundle in a sandbox.
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor82const BUNDLE_DIR: &str = "/work/g1t-bundle";
83/// Cloudflare's limits on a Worker's static assets.
84const MAX_FILES: usize = 20_000;
85const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;
86/// How much of the build's output is kept for the deployment's log.
87const MAX_LOG_CHARS: usize = 20_000;
88/// Directories a static build usually writes to, in the order they are tried.
89const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"];
90/// Bindings a Workers project may declare that are not provisioned yet.
91const UNSUPPORTED_BINDINGS: [&str; 10] = [
92 "kv_namespaces",
93 "d1_databases",
94 "r2_buckets",
95 "durable_objects",
96 "services",
97 "queues",
98 "vectorize",
99 "hyperdrive",
100 "ai",
101 "workflows",
102];
103
104struct Reporter {
105 base: String,
106 token: String,
107}
108
109impl Reporter {
110 fn send(&self, step: &str, mut body: Value) -> Result<Value> {
111 body["token"] = self.token.clone().into();
112 let response = ureq::post(&format!("{}/{step}", self.base))
113 .send_json(body)
114 .with_context(|| format!("could not report `{step}` to g1t"))?;
115 Ok(response.into_json().unwrap_or(Value::Null))
116 }
117}
118
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9119/// The build's log, kept to its end; also printed, in a workflow step.
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor120#[derive(Default)]
121struct Log {
122 text: String,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9123 /// Each line to standard output as well, redacted.
124 echo: Option<Vec<String>>,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor125}
126
127impl Log {
128 fn line(&mut self, line: &str) {
129 self.text.push_str(line);
130 self.text.push('\n');
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9131 if let Some(secrets) = &self.echo {
132 println!("{}", redact(line, secrets));
133 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor134 }
135
136 fn tail(&self) -> String {
137 let length = self.text.chars().count();
138 if length <= MAX_LOG_CHARS {
139 return self.text.clone();
140 }
141 let kept: String = self.text.chars().skip(length - MAX_LOG_CHARS).collect();
142 format!("… (earlier output not shown)\n{kept}")
143 }
144}
145
146/// Runs a command in the checkout, logging it; fails if it fails.
147fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> {
148 log.line(&format!("$ {command}"));
149 let result = run_command(command, &project_dir(), secrets);
150 if !result.output_text().is_empty() {
151 log.line(result.output_text());
152 }
153 if !result.passed {
154 bail!("`{command}` failed");
155 }
156 Ok(())
157}
158
159/// A Workers project's settings, from whichever config file it has.
160#[derive(Debug, Default, Deserialize)]
161struct WranglerConfig {
162 main: Option<String>,
163 compatibility_date: Option<String>,
164 #[serde(default)]
165 compatibility_flags: Vec<String>,
166 assets: Option<AssetsConfig>,
167 #[serde(default)]
168 vars: BTreeMap<String, Value>,
169 #[serde(flatten)]
170 rest: BTreeMap<String, Value>,
171}
172
173#[derive(Debug, Default, Deserialize)]
174struct AssetsConfig {
175 directory: Option<String>,
176 binding: Option<String>,
177 html_handling: Option<String>,
178 not_found_handling: Option<String>,
179}
180
181/// JSON with comments and trailing commas, as `wrangler.jsonc` allows.
182fn strip_jsonc(text: &str) -> String {
183 let mut out = String::with_capacity(text.len());
184 let mut chars = text.chars().peekable();
185 let mut in_string = false;
186 while let Some(c) = chars.next() {
187 if in_string {
188 out.push(c);
189 if c == '\\' {
190 if let Some(next) = chars.next() {
191 out.push(next);
192 }
193 } else if c == '"' {
194 in_string = false;
195 }
196 continue;
197 }
198 match (c, chars.peek()) {
199 ('"', _) => {
200 in_string = true;
201 out.push(c);
202 }
203 ('/', Some('/')) => {
204 for c in chars.by_ref() {
205 if c == '\n' {
206 out.push('\n');
207 break;
208 }
209 }
210 }
211 ('/', Some('*')) => {
212 chars.next();
213 let mut last = ' ';
214 for c in chars.by_ref() {
215 if last == '*' && c == '/' {
216 break;
217 }
218 last = c;
219 }
220 }
221 _ => out.push(c),
222 }
223 }
224 // Trailing commas before a closing bracket.
225 let mut cleaned = String::with_capacity(out.len());
226 let chars: Vec<char> = out.chars().collect();
227 let mut in_string = false;
228 let mut i = 0;
229 while i < chars.len() {
230 let c = chars[i];
231 if c == '"' && (i == 0 || chars[i - 1] != '\\') {
232 in_string = !in_string;
233 }
234 if c == ',' && !in_string {
235 let next = chars[i + 1..].iter().find(|c| !c.is_whitespace());
236 if matches!(next, Some('}') | Some(']')) {
237 i += 1;
238 continue;
239 }
240 }
241 cleaned.push(c);
242 i += 1;
243 }
244 cleaned
245}
246
247fn read_wrangler(dir: &Path) -> Result<Option<WranglerConfig>> {
248 for name in ["wrangler.jsonc", "wrangler.json"] {
249 let path = dir.join(name);
250 if path.exists() {
251 let text = std::fs::read_to_string(&path)?;
252 return Ok(Some(
253 serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {name}"))?,
254 ));
255 }
256 }
257 let path = dir.join("wrangler.toml");
258 if path.exists() {
259 let text = std::fs::read_to_string(&path)?;
260 return Ok(Some(toml::from_str(&text).context("could not read wrangler.toml")?));
261 }
262 Ok(None)
263}
264
265/// How to install the project's dependencies, judged by its lockfile.
266fn install_command(dir: &Path) -> Option<&'static str> {
267 if !dir.join("package.json").exists() {
268 return None;
269 }
270 Some(if dir.join("pnpm-lock.yaml").exists() {
271 "corepack enable && pnpm install --frozen-lockfile"
272 } else if dir.join("yarn.lock").exists() {
273 "corepack enable && yarn install"
274 } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
275 "npx --yes bun install"
276 } else if dir.join("package-lock.json").exists() {
277 "npm ci"
278 } else {
279 "npm install"
280 })
281}
282
283fn has_build_script(dir: &Path) -> bool {
284 std::fs::read_to_string(dir.join("package.json"))
285 .ok()
286 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
287 .is_some_and(|package| package["scripts"]["build"].is_string())
288}
289
290/// One file of the site, as Cloudflare's asset upload names it.
291struct Asset {
292 path: String,
293 hash: String,
294 size: u64,
295 file: PathBuf,
296}
297
298fn content_type(path: &str) -> &'static str {
299 let extension = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
300 match extension.as_str() {
301 "html" | "htm" => "text/html",
302 "css" => "text/css",
303 "js" | "mjs" => "application/javascript",
304 "json" | "map" => "application/json",
305 "svg" => "image/svg+xml",
306 "png" => "image/png",
307 "jpg" | "jpeg" => "image/jpeg",
308 "gif" => "image/gif",
309 "webp" => "image/webp",
310 "avif" => "image/avif",
311 "ico" => "image/x-icon",
312 "woff" => "font/woff",
313 "woff2" => "font/woff2",
314 "ttf" => "font/ttf",
315 "txt" => "text/plain",
316 "xml" => "application/xml",
317 "wasm" => "application/wasm",
318 "pdf" => "application/pdf",
319 "mp4" => "video/mp4",
320 "webm" => "video/webm",
321 _ => "application/octet-stream",
322 }
323}
324
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.325/// What is never served from the top of a site: a Pages-style Worker
326/// (`_worker.js`, its server code, which a Cloudflare adapter writes beside
327/// the site's files), `_routes.json`, and `.assetsignore`, with each name
328/// that `.assetsignore` lists, as wrangler leaves them out.
329fn ignored_at_root(root: &Path) -> BTreeSet<String> {
330 let mut ignored: BTreeSet<String> = ["_worker.js", "_routes.json", ".assetsignore"].map(str::to_owned).into();
331 if let Ok(text) = std::fs::read_to_string(root.join(".assetsignore")) {
332 ignored.extend(
333 text.lines()
334 .map(|line| line.trim().trim_matches('/'))
335 .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.contains(['*', '/', '!']))
336 .map(str::to_owned),
337 );
338 }
339 ignored
340}
341
342/// Every file of the site at `root`, but for what never belongs in one.
343fn collect_site(root: &Path, skip_project: bool) -> Result<Vec<Asset>> {
344 let mut assets = Vec::new();
345 collect(root, root, skip_project, &ignored_at_root(root), &mut assets)?;
346 Ok(assets)
347}
348
349fn collect(root: &Path, dir: &Path, skip_project: bool, ignored: &BTreeSet<String>, out: &mut Vec<Asset>) -> Result<()> {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor350 for entry in std::fs::read_dir(dir)? {
351 let entry = entry?;
352 let name = entry.file_name().to_string_lossy().into_owned();
353 let path = entry.path();
354 let kind = entry.file_type()?;
355 if name == ".git" || (skip_project && (name == "node_modules" || name.starts_with(".g1t"))) {
356 continue;
357 }
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.358 if dir == root && ignored.contains(&name) {
359 continue;
360 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor361 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.362 collect(root, &path, skip_project, ignored, out)?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor363 continue;
364 }
365 if !kind.is_file() || name == "_headers" || name == "_redirects" {
366 continue;
367 }
368 let size = entry.metadata()?.len();
369 let relative = path
370 .strip_prefix(root)?
371 .to_string_lossy()
372 .replace('\\', "/");
373 if size > MAX_FILE_BYTES {
374 bail!("{relative} is larger than Cloudflare's 25 MiB limit for one file");
375 }
376 let bytes = std::fs::read(&path)?;
377 let digest = hex::encode(Sha256::digest(&bytes));
378 out.push(Asset {
379 path: format!("/{relative}"),
380 hash: digest[..32].to_owned(),
381 size,
382 file: path,
383 });
384 if out.len() > MAX_FILES {
385 bail!("the site has more than {MAX_FILES} files, Cloudflare's limit");
386 }
387 }
388 Ok(())
389}
390
391/// The upload, as the API sends it: `snake_case`, or `uploadUrl` from
392/// before its bodies were.
393#[derive(Deserialize)]
394struct UploadSession {
395 jwt: String,
396 #[serde(default)]
397 buckets: Vec<Vec<String>>,
398 #[serde(alias = "uploadUrl")]
399 upload_url: String,
400}
401
402/// Sends one bucket of files with the upload's key. The last bucket's
403/// answer carries the key that completes the upload.
404fn upload_bucket(session: &UploadSession, bucket: &[String], by_hash: &BTreeMap<&str, &Asset>) -> Result<Option<String>> {
405 let boundary = format!("g1t-{}", hex::encode(Sha256::digest(bucket.join(",").as_bytes()))[..24].to_owned());
406 let mut body: Vec<u8> = Vec::new();
407 for hash in bucket {
408 let asset = by_hash
409 .get(hash.as_str())
410 .with_context(|| format!("Cloudflare asked for a file this build does not have ({hash})"))?;
411 let bytes = std::fs::read(&asset.file)?;
412 body.extend_from_slice(
413 format!(
414 "--{boundary}\r\nContent-Disposition: form-data; name=\"{hash}\"; filename=\"{hash}\"\r\nContent-Type: {}\r\n\r\n",
415 content_type(&asset.path)
416 )
417 .as_bytes(),
418 );
419 body.extend_from_slice(STANDARD.encode(bytes).as_bytes());
420 body.extend_from_slice(b"\r\n");
421 }
422 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
423 let response = ureq::post(&session.upload_url)
424 .set("authorization", &format!("Bearer {}", session.jwt))
425 .set("content-type", &format!("multipart/form-data; boundary={boundary}"))
426 .send_bytes(&body);
427 let response = match response {
428 Ok(response) => response,
429 Err(ureq::Error::Status(code, response)) => {
430 bail!("Cloudflare refused the upload ({code}): {}", response.into_string().unwrap_or_default())
431 }
432 Err(error) => bail!("could not upload to Cloudflare: {error}"),
433 };
434 let answer: Value = response.into_json().unwrap_or(Value::Null);
435 Ok(answer["result"]["jwt"].as_str().map(str::to_owned))
436}
437
438#[derive(Serialize)]
439#[serde(rename_all = "camelCase")]
440struct Module {
441 name: String,
442 content_base64: String,
443 content_type: String,
444}
445
446/// The bundle `wrangler deploy --dry-run` wrote, main module first.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9447fn bundle_modules(main: &str, bundle: &Path) -> Result<(String, Vec<Module>)> {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor448 let stem = Path::new(main)
449 .file_stem()
450 .map(|stem| stem.to_string_lossy().into_owned())
451 .unwrap_or_else(|| "index".to_owned());
452 let mut modules = Vec::new();
453 let mut files = Vec::new();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9454 collect_files(bundle, &mut files)?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor455 for file in files {
456 let name = file
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9457 .strip_prefix(bundle)?
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor458 .to_string_lossy()
459 .replace('\\', "/");
460 let kind = match name.rsplit('.').next().unwrap_or("") {
461 "js" | "mjs" => "application/javascript+module",
462 "wasm" => "application/wasm",
463 "map" | "md" => continue,
464 _ => "text/plain",
465 };
466 modules.push(Module {
467 content_base64: STANDARD.encode(std::fs::read(&file)?),
468 content_type: kind.to_owned(),
469 name,
470 });
471 }
472 let main_name = modules
473 .iter()
474 .map(|module| module.name.clone())
475 .find(|name| *name == format!("{stem}.js") || *name == format!("{stem}.mjs"))
476 .or_else(|| {
477 modules
478 .iter()
479 .find(|module| module.content_type == "application/javascript+module")
480 .map(|module| module.name.clone())
481 })
482 .context("wrangler wrote no JavaScript module")?;
483 Ok((main_name, modules))
484}
485
486fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
487 for entry in std::fs::read_dir(dir)? {
488 let entry = entry?;
489 if entry.file_type()?.is_dir() {
490 collect_files(&entry.path(), out)?;
491 } else {
492 out.push(entry.path());
493 }
494 }
495 Ok(())
496}
497
498/// What a Workers config asks for that g1t.page does not provision, said
499/// as the deployment's warnings: bindings it runs without, and scheduled
500/// (`triggers.crons`) runs that never fire.
501fn 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.502 // An empty list, as a config a framework's build writes has for each
503 // kind of binding (`"d1_databases": []`), declares nothing.
504 fn declares(value: &Value) -> bool {
505 match value {
506 Value::Null => false,
507 Value::Array(items) => !items.is_empty(),
508 Value::Object(fields) => fields.values().any(declares),
509 _ => true,
510 }
511 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor512 let mut warnings = Vec::new();
513 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.514 if config.rest.get(binding).is_some_and(declares) {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor515 warnings.push(format!("`{binding}` is not provisioned on g1t.page yet, so the app runs without it."));
516 }
517 }
518 let crons = config
519 .rest
520 .get("triggers")
521 .and_then(|triggers| triggers.get("crons"))
522 .and_then(Value::as_array)
523 .map_or(0, |crons| crons.iter().filter(|cron| cron.as_str().is_some_and(|c| !c.trim().is_empty())).count());
524 if crons > 0 {
525 let schedules = if crons == 1 { "schedule" } else { "schedules" };
526 warnings.push(format!(
527 "`triggers.crons` ({crons} {schedules}) is not set up on g1t.page yet, so the Worker's `scheduled` handler never runs."
528 ));
529 }
530 warnings
531}
532
533/// What was built: the Worker's code and settings, and where its site is.
534struct Built {
535 worker: Value,
536 assets_dir: Option<PathBuf>,
537 warnings: Vec<String>,
538 /// 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.539 /// `workers`, `static` (a build's output), `html` (the files as they
540 /// are) or `container`.
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor541 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.542 /// The framework it was found to be built with, if any (`astro`…), or
543 /// for a container what it was built from (`dockerfile`, `flask`…).
544 framework: Option<&'static str>,
545 /// For a container: the image pushed, its digest, port and size.
546 container: Option<Value>,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor547}
548
549/// What a project without a Workers config is: a site it built, or its own
550/// files served as they are.
551fn static_kind(project: &Path, assets_dir: &Path, built: bool) -> &'static str {
552 if !built && assets_dir == project { "html" } else { "static" }
553}
554
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9555/// 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.556/// into `bundle` when given, by the config at `config` when given.
557fn wrangler_build(bundle: Option<&Path>, config: Option<&Path>) -> String {
558 let mut command = "npx --yes wrangler@4 deploy --dry-run".to_owned();
559 if let Some(config) = config {
560 command.push_str(&format!(" --config {}", config.display()));
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9561 }
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.562 if let Some(bundle) = bundle {
563 command.push_str(&format!(" --outdir {}", bundle.display()));
564 }
565 command
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9566}
567
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.568/// The config a framework's build wrote for wrangler, which wrangler reads
569/// in place of the project's own (`.wrangler/deploy/config.json`, as
570/// `@cloudflare/vite-plugin` writes it), with the folder its paths are
571/// relative to.
572fn redirected_config(dir: &Path) -> Result<Option<(WranglerConfig, PathBuf)>> {
573 let pointer = dir.join(".wrangler/deploy/config.json");
574 let Ok(text) = std::fs::read_to_string(&pointer) else { return Ok(None) };
575 let Some(path) = serde_json::from_str::<Value>(&text).ok().and_then(|v| v["configPath"].as_str().map(str::to_owned)) else {
576 return Ok(None);
577 };
578 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.579 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.580 let text = std::fs::read_to_string(&path).with_context(|| format!("could not read {}", path.display()))?;
581 let config = serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {}", path.display()))?;
582 Ok(Some((config, path.parent().map_or_else(|| dir.to_path_buf(), Path::to_path_buf))))
583}
584
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.585/// The path, resolved, as a plain path. On Windows, `canonicalize` gives a
586/// verbatim one (`\\?\C:\…`), in which `/` and `..` are not read as
587/// separators or as the folder above: a config's `"directory": "../client"`
588/// joined to it would name no folder. A drive path loses the prefix; a
589/// network path keeps it, as it cannot do without.
590fn canonical(path: &Path) -> std::io::Result<PathBuf> {
591 let path = path.canonicalize()?;
592 #[cfg(windows)]
593 {
594 let text = path.to_string_lossy();
595 if let Some(rest) = text.strip_prefix(r"\\?\") {
596 if !rest.starts_with(r"UNC\") {
597 return Ok(PathBuf::from(rest));
598 }
599 }
600 }
601 Ok(path)
602}
603
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.604/// The entry of the Worker a framework's adapter wrote, once it has built:
605/// the file, or a directory's `index.js`.
606fn adapter_entry(dir: &Path, adapter: &Adapter) -> Option<PathBuf> {
607 let entry = dir.join(adapter.entry);
608 if entry.is_file() {
609 return Some(entry);
610 }
611 ["index.js", "index.mjs"].iter().map(|name| entry.join(name)).find(|index| index.is_file())
612}
613
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9614/// The first of the usual output directories a static build wrote (or the
615/// project's own `index.html`), relative to the project: `.` for itself.
616fn find_output(dir: &Path) -> Option<String> {
617 OUTPUT_DIRS
618 .iter()
619 .find(|name| {
620 let path = dir.join(name);
621 path.join("index.html").exists() || (path.is_dir() && **name != "public")
622 })
623 .map(|name| (*name).to_owned())
624 .or_else(|| dir.join("index.html").exists().then(|| ".".to_owned()))
625}
626
627/// What a build of the project at `dir` does, found before anything runs:
628/// what it is, how its dependencies are installed, what builds it, and
629/// where what it serves is. For a site with a build of its own, the output
630/// 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.631/// unless one was chosen or the framework says where it writes.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9632#[derive(Debug, PartialEq, Eq)]
633pub(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.634 /// `workers`, `static`, `html`, `container`, or `none` when there is
635 /// nothing to serve.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9636 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.637 /// The framework it is built with, if g1t knows it.
638 pub framework: Option<Framework>,
639 pub install: Option<String>,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9640 pub build: Option<String>,
641 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.642 /// Why it cannot be served as it is set up, and what to change: the
643 /// kind is then `none`.
644 pub problem: Option<String>,
645 /// What runs before wrangler bundles a Workers project; for anything
646 /// else, `build`.
647 pre_build: Option<String>,
648 /// For a container: its Dockerfile, or the one g1t writes.
649 pub container: Option<ContainerBuild>,
650 /// What it would be served as without its Dockerfile, when it is a
651 /// container only for having one: built so while g1t.page runs no
652 /// containers (`not_enabled` from the registry step).
653 fallback: Option<Box<Detected>>,
654}
655
656/// How a container is built.
657#[derive(Debug, Clone, PartialEq, Eq)]
658pub(crate) struct ContainerBuild {
659 /// The project's own Dockerfile, relative to the project; none when
660 /// g1t writes one.
661 pub dockerfile: Option<String>,
662 /// The Dockerfile g1t writes, for a server it recognises.
663 pub generated: Option<container::Server>,
664 /// What it listens on: its Dockerfile's `EXPOSE`, else 8080. `PORT`
665 /// is set to it.
666 pub port: u16,
667}
668
669impl Detected {
670 fn nothing(framework: Option<Framework>, install: Option<String>, problem: String) -> Detected {
671 Detected {
672 kind: "none",
673 framework,
674 install,
675 build: None,
676 output_dir: None,
677 problem: Some(problem),
678 pre_build: None,
679 container: None,
680 fallback: None,
681 }
682 }
683
684 /// The `framework` `g1t-runner detect` prints: the framework, else for
685 /// a container the server g1t wrote a Dockerfile for (`flask`…) or
686 /// `dockerfile`.
687 pub(crate) fn framework_id(&self) -> Option<&'static str> {
688 self.framework.as_ref().map(|f| f.id).or_else(|| {
689 self.container.as_ref().map(|c| c.generated.as_ref().map_or("dockerfile", |server| server.id))
690 })
691 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9692}
693
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.694/// Why nothing could be found to serve, and what to add.
695const NOTHING_TO_SERVE: &str = "found nothing to serve: no Workers config, no Dockerfile, no index.html, and none of dist, build, out, \
696 public, _site or .output/public. Add a `Dockerfile` to deploy it as a container, or set **Build command** and \
697 **Output directory** for a static site.";
698
699/// The project as a container: its own Dockerfile, else one g1t writes
700/// for `framework` or for a server it recognises. None when it has neither.
701fn as_container(dir: &Path, framework: Option<Framework>, own: Option<String>) -> Option<Detected> {
702 let build = match own {
703 Some(path) => {
704 let text = std::fs::read_to_string(dir.join(&path)).unwrap_or_default();
705 ContainerBuild { port: container::exposed_port(&text).unwrap_or(container::DEFAULT_PORT), dockerfile: Some(path), generated: None }
706 }
707 None => {
708 let server = match &framework {
709 // One set up for Workers (OpenNext's adapter) is not run as a server.
710 Some(framework) if framework.workers => None,
711 Some(framework) => container::for_framework(dir, framework.id),
712 None => container::server(dir),
713 }?;
714 ContainerBuild { dockerfile: None, generated: Some(server), port: container::DEFAULT_PORT }
715 }
716 };
717 let file = build.dockerfile.clone().unwrap_or_else(|| "Dockerfile (written by g1t)".to_owned());
718 Some(Detected {
719 kind: "container",
720 framework,
721 // The image installs what it needs: nothing runs before its build.
722 install: None,
723 build: Some(format!("docker build -f {file} .")),
724 output_dir: None,
725 problem: None,
726 pre_build: None,
727 container: Some(build),
728 fallback: None,
729 })
730}
731
732#[cfg(test)]
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9733pub(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.734 detect_as(dir, custom_build, chosen_output, BuildAs::Auto, None)
735}
736
737/// What the project is, by its **Build as** setting (`build_as`) and its
738/// Dockerfile's path (`dockerfile`, when it chose one):
739///
740/// - **Docker**: a container, from its Dockerfile or one g1t writes.
741/// - **Static site**: built and served as files, whatever else it has.
742/// - **Workers**: its Wrangler config or a framework adapter's Worker.
743/// - **Auto**: a Wrangler config is a Workers project; a framework that
744/// builds a static site or a Worker is that, which costs nothing while
745/// no one visits; a framework set up to need a server is a container
746/// when it has a Dockerfile, or when g1t can write one; a project with
747/// no framework g1t knows is a container when it has a Dockerfile or is
748/// a server g1t recognises (a Node.js, Python, Go, Ruby or PHP app),
749/// else a static site, else nothing, saying to add a Dockerfile.
750pub(crate) fn detect_as(
751 dir: &Path,
752 custom_build: Option<&str>,
753 chosen_output: Option<&str>,
754 build_as: BuildAs,
755 dockerfile: Option<&str>,
756) -> Result<Detected> {
757 let own = container::own_dockerfile(dir, dockerfile)?;
758 match build_as {
759 BuildAs::Docker => {
760 let framework = frameworks::detect(dir, read_wrangler(dir)?.is_some());
761 Ok(as_container(dir, framework.clone(), own).unwrap_or_else(|| {
762 Detected::nothing(
763 framework,
764 None,
765 "**Build as** is Docker, but this project has no Dockerfile and g1t doesn't recognise a server it can write \
766 one for. Add a `Dockerfile` (or set its path under Settings, Deployments)."
767 .to_owned(),
768 )
769 }))
770 }
771 BuildAs::Static => detect_site(dir, custom_build, chosen_output, true),
772 BuildAs::Workers => {
773 let site = detect_site(dir, custom_build, chosen_output, false)?;
774 if site.kind == "workers" {
775 return Ok(site);
776 }
777 Ok(Detected::nothing(
778 site.framework,
779 site.install,
780 "**Build as** is Workers, but this project has no Wrangler config (`wrangler.jsonc`, `wrangler.json` or \
781 `wrangler.toml`) and no framework adapter that writes a Worker."
782 .to_owned(),
783 ))
784 }
785 BuildAs::Auto => {
786 let site = detect_site(dir, custom_build, chosen_output, false)?;
787 if site.kind == "workers" || (site.framework.is_some() && site.problem.is_none()) {
788 return Ok(site);
789 }
790 if let Some(problem) = site.problem.clone() {
791 // A framework set up to need a server.
792 let found = as_container(dir, site.framework.clone(), own);
793 return Ok(found.unwrap_or_else(|| {
794 Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container."))
795 }));
796 }
797 let has_own = own.is_some();
798 if let Some(mut found) = as_container(dir, None, own) {
799 // A Dockerfile beside a site that could be served as it is.
800 if has_own && (site.kind == "static" || site.kind == "html") {
801 found.fallback = Some(Box::new(site));
802 }
803 return Ok(found);
804 }
805 if site.kind == "none" {
806 return Ok(Detected::nothing(site.framework, site.install, NOTHING_TO_SERVE.to_owned()));
807 }
808 Ok(site)
809 }
810 }
811}
812
813/// What the project is as a static site or Workers project: `as_static`
814/// ignores its Wrangler config and any framework adapter, and builds it
815/// even when its framework is set up to need a server.
816fn 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-7nm8r9817 let custom_build = custom_build.map(str::trim).filter(|c| !c.is_empty());
818 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.819 let config = if as_static { None } else { read_wrangler(dir)? };
820 let framework = frameworks::detect(dir, config.is_some());
821 let installs: Vec<&str> = [install_command(dir), framework.as_ref().and_then(|f| f.install)].into_iter().flatten().collect();
822 let install = (!installs.is_empty()).then(|| installs.join(" && "));
823 let framework_build = framework.as_ref().and_then(|f| f.build.clone());
824 if let Some(config) = config {
825 // A framework built for Workers builds before wrangler bundles what
826 // it wrote; any other project's config builds it, as it always has.
827 let pre_build = custom_build
828 .map(str::to_owned)
829 .or_else(|| framework.as_ref().filter(|f| f.workers).and(framework_build));
830 let mut steps: Vec<String> = pre_build.clone().into_iter().collect();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9831 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.832 steps.push(wrangler_build(None, None));
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9833 }
834 return Ok(Detected {
835 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.836 framework,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9837 install,
838 build: (!steps.is_empty()).then(|| steps.join(" && ")),
839 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.840 problem: None,
841 pre_build,
842 container: None,
843 fallback: None,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9844 });
845 }
846 let build = custom_build
847 .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.848 .or(framework_build)
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9849 .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.850 // An adapter's Worker, unless the project chose a folder to serve.
851 if !as_static && chosen_output.is_none() && framework.as_ref().is_some_and(|f| f.adapter.is_some()) {
852 // Where its files are is known once it has built: the adapter's
853 // own folder, or where the config it writes says.
854 return Ok(Detected {
855 kind: "workers",
856 output_dir: None,
857 framework,
858 install,
859 pre_build: build.clone(),
860 build,
861 problem: None,
862 container: None,
863 fallback: None,
864 });
865 }
866 // A command or a folder of the project's own says it knows better, as
867 // does **Build as** set to Static site.
868 let problem = framework
869 .as_ref()
870 .and_then(|f| f.problem.clone())
871 .filter(|_| !as_static && custom_build.is_none() && chosen_output.is_none());
872 if let Some(problem) = problem {
873 return Ok(Detected::nothing(framework, install, problem));
874 }
875 let output_dir = chosen_output
876 .map(str::to_owned)
877 .or_else(|| framework.as_ref().and_then(|f| f.output.clone()))
878 .or_else(|| find_output(dir));
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9879 // `.` is the project itself: its own files, served as they are.
880 let kind = match (&build, output_dir.as_deref()) {
881 (None, Some(".")) => "html",
882 (None, None) => "none",
883 _ => "static",
884 };
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.885 Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None })
886}
887
888/// The compatibility date a Worker gets when its config names none.
889const COMPATIBILITY_DATE: &str = "2026-09-26";
890
891/// A Workers project bundled by wrangler: `config` with its paths relative
892/// to `base`, read by wrangler from `config_file` when given (else the
893/// project's own, or the one its build wrote).
894fn build_worker(
895 log: &mut Log,
896 secrets: &[String],
897 config: WranglerConfig,
898 base: &Path,
899 config_file: Option<&Path>,
900) -> Result<(Value, Option<PathBuf>, Vec<String>)> {
901 let bundle = bundle_dir();
902 let warnings = config_warnings(&config);
903 let mut worker = json!({
904 "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| COMPATIBILITY_DATE.to_owned()),
905 "compatibilityFlags": config.compatibility_flags,
906 "vars": config.vars,
907 });
908 if let Some(main) = &config.main {
909 // `--dry-run` runs the config's own build and bundles it, without
910 // deploying anywhere.
911 let _ = std::fs::remove_dir_all(&bundle);
912 step(log, &wrangler_build(Some(&bundle), config_file), secrets)?;
913 let (main_module, modules) = bundle_modules(main, &bundle)?;
914 worker["mainModule"] = main_module.into();
915 worker["modules"] = serde_json::to_value(modules)?;
916 }
917 let assets = config.assets.unwrap_or_default();
918 let assets_dir = assets.directory.as_ref().map(|directory| base.join(directory));
919 worker["assetsBinding"] = assets.binding.into();
920 worker["htmlHandling"] = assets.html_handling.into();
921 worker["notFoundHandling"] = assets.not_found_handling.into();
922 Ok((worker, assets_dir, warnings))
923}
924
925/// Where the config for an adapter's Worker is written: beside the
926/// bundle, never in the checkout.
927fn adapter_config_path() -> PathBuf {
928 let bundle = bundle_dir();
929 bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy()))
930}
931
932/// What a container build says when g1t.page runs no containers.
933const CONTAINERS_OFF: &str = "This project deploys as a container, and Docker deployments are not switched on for g1t.page yet. \
934 Set **Build as** to Static site or Workers under Settings, Deployments if it can be built as one.";
935
936/// Where a container's registry login is kept for the build: its own
937/// folder, never the job's `~/.docker`.
938fn docker_config_dir() -> PathBuf {
939 bundle_dir().with_file_name(format!("{}.docker", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
940}
941
942/// Where the Dockerfile g1t writes for a project without one goes: beside
943/// the bundle, never in the checkout.
944fn generated_dockerfile_path() -> PathBuf {
945 bundle_dir().with_file_name(format!("{}.Dockerfile", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
946}
947
948/// What the deployments service answers the `registry` step with: where
949/// to push the app's image, with a login good for pushing only and only
950/// for minutes, and the largest image the app's instance can run.
951#[derive(Debug, Deserialize)]
952struct RegistryGrant {
953 registry: String,
954 repository: String,
955 tag: String,
956 username: String,
957 password: String,
958 #[serde(alias = "maxImageBytes")]
959 max_image_bytes: u64,
960 #[serde(default, alias = "instanceType")]
961 instance_type: Option<String>,
962}
963
964/// `name@sha256:…` for `repository` among an image's repository digests,
965/// as `docker image inspect` lists them, one a line.
966fn pushed_digest(repo_digests: &str, registry: &str, repository: &str) -> Option<String> {
967 let prefix = format!("{registry}/{repository}@sha256:");
968 repo_digests
969 .lines()
970 .map(str::trim)
971 .find(|line| line.starts_with(&prefix) && line.len() == prefix.len() + 64)
972 .map(str::to_owned)
973}
974
975/// Bytes, as a person reads them: `1.4 GB`.
976fn size_text(bytes: u64) -> String {
977 format!("{:.1} GB", bytes as f64 / 1_000_000_000.0)
978}
979
980/// Runs `docker` with `args`, signed in through `config`; its output, or
981/// why it failed, redacted.
982fn docker(args: &[&str], config: &Path, input: Option<&str>, secrets: &[String]) -> Result<String> {
983 use std::io::Write;
984 use std::process::{Command, Stdio};
985 let mut command = Command::new("docker");
986 command.args(args).env("DOCKER_CONFIG", config).stdout(Stdio::piped()).stderr(Stdio::piped());
987 command.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() });
988 let mut child = command.spawn().context("could not run docker")?;
989 if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
990 stdin.write_all(input.as_bytes())?;
991 }
992 let output = child.wait_with_output()?;
993 let text = redact(&format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)), secrets);
994 if !output.status.success() {
995 bail!("`docker {}` failed: {}", args.first().copied().unwrap_or_default(), text.trim());
996 }
997 Ok(text)
998}
999
1000/// Builds the project's container image with Docker and pushes it to
1001/// Cloudflare's registry, at the place and with the short-lived push-only
1002/// login the deployments service gives for this build (`registry`). The
1003/// sandbox never holds g1t's own Cloudflare token. None when g1t.page runs
1004/// no containers (the service answers `not_enabled`).
1005fn container_build(reporter: &Reporter, log: &mut Log, secrets: &[String], dir: &Path, plan: &Detected) -> Result<Option<Value>> {
1006 let build = plan.container.as_ref().context("not a container")?;
1007 // Asked first: a build that could not be pushed is not run.
1008 let answer = reporter.send("registry", json!({}))?;
1009 if answer["ok"] == false {
1010 if answer["error"]["code"] == "not_enabled" {
1011 return Ok(None);
1012 }
1013 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the image's upload"));
1014 }
1015 let grant: RegistryGrant =
1016 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the image's upload was not understood")?;
1017 let mut secrets = secrets.to_vec();
1018 secrets.push(grant.password.clone());
1019 let dockerfile = match (&build.dockerfile, &build.generated) {
1020 (Some(own), _) => {
1021 log.line(&format!("Building a container from {own}, listening on port {}.", build.port));
1022 dir.join(own)
1023 }
1024 (None, Some(server)) => {
1025 // Shown whole, so it can be committed and changed.
1026 log.line(&format!("This {} project has no Dockerfile, so g1t wrote this one (commit it as `Dockerfile` to change it):", server.name));
1027 for line in server.dockerfile.lines() {
1028 log.line(&format!(" {line}"));
1029 }
1030 let path = generated_dockerfile_path();
1031 std::fs::write(&path, &server.dockerfile)?;
1032 // BuildKit reads `<Dockerfile>.dockerignore` beside it.
1033 std::fs::write(path.with_file_name(format!("{}.dockerignore", path.file_name().unwrap_or_default().to_string_lossy())), container::DOCKERIGNORE)?;
1034 path
1035 }
1036 (None, None) => bail!("nothing to build the container from"),
1037 };
1038 if std::env::var("BUILD_COMMAND").is_ok_and(|c| !c.trim().is_empty()) {
1039 log.line("Build command is not used for a container: its Dockerfile builds it.");
1040 }
1041 // A sandbox's own Docker Engine, started for the build; in a workflow
1042 // step, the job's.
1043 if clones() {
1044 if std::env::var("G1T_DOCKER").as_deref() != Ok("on") {
1045 bail!("Docker is turned off for g1t's sandboxes here, so a container cannot be built.");
1046 }
1047 crate::docker::engine::enable(crate::docker::engine::Options::default())
1048 .map_err(|problem| anyhow::anyhow!("Docker is not available in this sandbox: {problem}"))?;
1049 let started = crate::docker::engine::ensure_started();
1050 for note in crate::docker::take_notes() {
1051 log.line(&note);
1052 }
1053 started.map_err(|problem| anyhow::anyhow!("Docker could not start: {problem}"))?;
1054 }
1055 let config = docker_config_dir();
1056 std::fs::create_dir_all(&config)?;
1057 let image = format!("{}/{}:{}", grant.registry, grant.repository, grant.tag);
1058 let quote = |path: &Path| format!("'{}'", path.display().to_string().replace('\'', r"'\''"));
1059 let built = step(
1060 log,
1061 &format!("docker build --platform linux/amd64 --pull -f {} -t {image} .", quote(&dockerfile)),
1062 &secrets,
1063 );
1064 let pushed = built.and_then(|()| {
1065 let size: u64 = docker(&["image", "inspect", "--format", "{{.Size}}", &image], &config, None, &secrets)?.trim().parse().unwrap_or(0);
1066 log.line(&format!("The image is {}.", size_text(size)));
1067 if size > grant.max_image_bytes {
1068 bail!(
1069 "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.",
1070 size_text(size),
1071 size_text(grant.max_image_bytes),
1072 grant.instance_type.as_deref().unwrap_or("its instance type"),
1073 );
1074 }
1075 docker(&["login", &grant.registry, "--username", &grant.username, "--password-stdin"], &config, Some(&grant.password), &secrets)?;
1076 log.line(&format!("$ docker push {image}"));
1077 let output = docker(&["push", &image], &config, None, &secrets)?;
1078 log.line(output.trim_end());
1079 let digests = docker(&["image", "inspect", "--format", "{{join .RepoDigests \"\\n\"}}", &image], &config, None, &secrets)?;
1080 let reference = pushed_digest(&digests, &grant.registry, &grant.repository).context("the pushed image's digest was not found")?;
1081 Ok(json!({
1082 "image": reference,
1083 "port": build.port,
1084 "sizeBytes": size,
1085 "dockerfile": build.dockerfile,
1086 "generated": build.generated.as_ref().map(|server| server.id),
1087 }))
1088 });
1089 let _ = std::fs::remove_dir_all(&config);
1090 let _ = std::fs::remove_file(generated_dockerfile_path());
1091 pushed.map(Some)
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91092}
1093
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.1094fn build(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Built> {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1095 let project = project_dir();
1096 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.1097 let custom_build = std::env::var("BUILD_COMMAND").ok();
1098 let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty());
1099 let build_as = BuildAs::parse(&std::env::var("BUILD_AS").unwrap_or_default());
1100 let dockerfile = std::env::var("DOCKERFILE").ok();
1101 let mut plan = detect_as(dir, custom_build.as_deref(), chosen.as_deref(), build_as, dockerfile.as_deref())?;
1102 if let Some(framework) = &plan.framework {
1103 log.line(&format!("Detected {}: framework={}.", framework.name, framework.id));
1104 }
1105 // Said before anything is installed: it would fail after, less clearly.
1106 if let Some(problem) = &plan.problem {
1107 bail!("{problem}");
1108 }
1109 if plan.container.is_some() {
1110 match container_build(reporter, log, secrets, dir, &plan)? {
1111 Some(image) => {
1112 return Ok(Built {
1113 worker: json!({}),
1114 assets_dir: None,
1115 warnings: Vec::new(),
1116 detected: "container",
1117 framework: plan.framework_id(),
1118 container: Some(image),
1119 });
1120 }
1121 // g1t.page runs no containers yet: the site it is without its
1122 // Dockerfile, as it was built before.
1123 None => match plan.fallback.take() {
1124 Some(site) => {
1125 log.line(&format!("Building it as a {} site instead, as it is without its Dockerfile.", if site.kind == "html" { "plain HTML" } else { "static" }));
1126 plan = *site;
1127 }
1128 None => bail!("{CONTAINERS_OFF}"),
1129 },
1130 }
1131 }
1132 let framework_id = plan.framework.as_ref().map(|f| f.id);
1133 if let Some(install) = &plan.install {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1134 step(log, install, secrets)?;
1135 }
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.1136 if let Some(command) = &plan.pre_build {
1137 step(log, command, secrets)?;
1138 }
1139 // A static site (by **Build as**, or found to be one) never reads a
1140 // Workers config.
1141 let workers = plan.kind == "workers";
1142 let own_config = if workers { read_wrangler(dir)? } else { None };
1143 // A framework built for Workers may write the config wrangler reads
1144 // (`@cloudflare/vite-plugin` does, and Astro's adapter with it), with
1145 // or without one of the project's own.
1146 let written = if own_config.is_some() || (workers && plan.framework.as_ref().is_some_and(|f| f.workers)) {
1147 redirected_config(dir)?
1148 } else {
1149 None
1150 };
1151 if let Some((config, base)) = written {
1152 log.line(&format!("Using the Workers config the build wrote, in {}.", base.display()));
1153 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, &base, None)?;
1154 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1155 }
1156 if let Some(config) = own_config {
1157 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, dir, None)?;
1158 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1159 }
1160 let adapter = plan.framework.as_ref().and_then(|f| f.adapter.as_ref()).filter(|_| workers && chosen.is_none());
1161 if let Some(adapter) = adapter
1162 && let Some(entry) = adapter_entry(dir, adapter)
1163 {
1164 // The adapter's Worker, bundled as a Workers project's is. Its
1165 // static files are uploaded by g1t, so wrangler is given none.
1166 let file = adapter_config_path();
1167 let flags = vec!["nodejs_compat".to_owned()];
1168 std::fs::write(
1169 &file,
1170 serde_json::to_string_pretty(&json!({
1171 "name": "g1t-app",
1172 "main": entry,
1173 "compatibility_date": COMPATIBILITY_DATE,
1174 "compatibility_flags": flags,
1175 }))?,
1176 )?;
1177 log.line(&format!("Bundling the Worker the adapter wrote, {}.", adapter.entry));
1178 let config = WranglerConfig {
1179 main: Some(entry.to_string_lossy().into_owned()),
1180 compatibility_flags: flags,
1181 assets: Some(AssetsConfig {
1182 directory: Some(adapter.assets.to_owned()),
1183 binding: Some("ASSETS".to_owned()),
1184 ..AssetsConfig::default()
1185 }),
1186 ..WranglerConfig::default()
1187 };
1188 let built = build_worker(log, secrets, config, dir, Some(&file));
1189 let _ = std::fs::remove_file(&file);
1190 let (worker, assets_dir, warnings) = built?;
1191 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1192 }
1193 if let (Some(adapter), Some(framework)) = (adapter, &plan.framework)
1194 && framework.output.is_none()
1195 {
1196 bail!("{}'s build did not write its Worker, `{}`", framework.name, adapter.entry);
1197 }
1198 let assets_dir = match &chosen {
1199 Some(chosen) => {
1200 let path = dir.join(chosen.trim().trim_matches('/'));
1201 if !path.is_dir() {
1202 bail!("the output directory `{chosen}` does not exist after the build");
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1203 }
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.1204 path
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1205 }
1206 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.1207 let expected = plan.framework.as_ref().and_then(|f| f.output.as_deref());
1208 match expected.filter(|output| dir.join(output).is_dir()) {
1209 Some(output) => dir.join(output),
1210 None => {
1211 let found = find_output(dir).with_context(|| match (&plan.framework, expected) {
1212 (Some(framework), Some(expected)) => format!(
1213 "found nothing to serve: {} writes its site to `{expected}`, which the build did not write. \
1214 Set **Output directory** if it writes somewhere else.",
1215 framework.name
1216 ),
1217 (Some(framework), None) if framework.problem.is_some() => {
1218 format!("found nothing to serve. {}", framework.problem.as_deref().unwrap_or_default())
1219 }
1220 _ => "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public".to_owned(),
1221 })?;
1222 if let Some(expected) = expected {
1223 log.line(&format!("`{expected}` was not written; serving `{found}`."));
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1224 }
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.1225 if found == "." { dir.to_path_buf() } else { dir.join(found) }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1226 }
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.1227 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1228 }
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.1229 };
1230 let spa = !assets_dir.join("404.html").exists();
1231 Ok(Built {
1232 worker: json!({
1233 "compatibilityDate": COMPATIBILITY_DATE,
1234 "compatibilityFlags": [],
1235 "vars": {},
1236 "notFoundHandling": if spa { "single-page-application" } else { "404-page" },
1237 }),
1238 detected: static_kind(dir, &assets_dir, plan.build.is_some()),
1239 assets_dir: Some(assets_dir),
1240 warnings: Vec::new(),
1241 framework: framework_id,
1242 container: None,
1243 })
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1244}
1245
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91246/// Whether the build checks out its own commit (a sandbox, given
1247/// `GIT_REMOTE`) rather than building the checkout it runs in.
1248fn clones() -> bool {
1249 std::env::var("GIT_REMOTE").is_ok_and(|remote| !remote.trim().is_empty())
1250}
1251
1252/// The checkout the build runs in: the sandbox's clone, or, from a
1253/// workflow step, the current directory.
1254fn checkout_dir() -> PathBuf {
1255 if clones() {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1256 PathBuf::from(WORKDIR)
1257 } else {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91258 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1259 }
1260}
1261
1262/// Where `wrangler deploy --dry-run` writes the bundle: in a workflow
1263/// step, a directory of the job's temporary space, never the checkout.
1264fn bundle_dir() -> PathBuf {
1265 if clones() {
1266 return PathBuf::from(BUNDLE_DIR);
1267 }
1268 let base = std::env::var_os("RUNNER_TEMP").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
1269 base.join(format!("g1t-bundle-{}", std::process::id()))
1270}
1271
1272/// Where the project lives in the checkout `base`: its root directory,
1273/// never above the checkout.
1274fn project_dir_in(base: &Path, root: &str) -> PathBuf {
1275 let root = root.trim().trim_matches('/');
1276 if root.is_empty() || root == "." || root.split('/').any(|part| part == "..") {
1277 base.to_path_buf()
1278 } else {
1279 base.join(root)
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1280 }
1281}
1282
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91283/// Where the project lives: `ROOT_DIR` in the checkout.
1284fn project_dir() -> PathBuf {
1285 project_dir_in(&checkout_dir(), &std::env::var("ROOT_DIR").unwrap_or_default())
1286}
1287
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1288fn check_out(secrets: &[String]) -> Result<()> {
1289 let remote = env("GIT_REMOTE")?;
1290 let commit = env("GIT_COMMIT")?;
1291 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
1292 std::fs::create_dir_all("/work")?;
Previews build from any branch; Agent in place of Ask AI; pin from the sidebar1293 let workdir = Path::new(WORKDIR);
1294 // The clone is shallow and of the default branch; a preview's commit is
1295 // often on another one, so it is fetched before checking out.
1296 let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR)
1297 .and_then(|_| git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"]))
1298 .and_then(|branch| crate::clone::ensure(workdir, &auth, "origin", &branch, &commit))
1299 .and_then(|_| git(workdir, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit]));
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1300 if let Err(error) = cloned {
1301 bail!("{}", redact(&format!("{error:#}"), secrets));
1302 }
1303 Ok(())
1304}
1305
1306fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91307 if clones() {
1308 check_out(secrets).context("the commit could not be checked out")?;
1309 } else {
1310 let dir = project_dir();
1311 if !dir.is_dir() {
1312 bail!("the project's directory {} does not exist in this checkout", dir.display());
1313 }
1314 log.line(&format!("Building {} as it is checked out.", dir.display()));
1315 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1316 // The repository's variables and secrets for deploy builds.
1317 for source in ["BUILD_ENV", "BUILD_SECRETS"] {
1318 if let Ok(vars) = std::env::var(source)
1319 && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
1320 {
1321 for (name, value) in vars {
1322 if let Some(value) = value.as_str() {
1323 // SAFETY: single-threaded; set before any command runs.
1324 unsafe { std::env::set_var(name, value) };
1325 }
1326 }
1327 }
1328 }
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.1329 let built = build(reporter, log, secrets)?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1330 let mut finish = json!({
1331 "worker": built.worker,
1332 "warnings": built.warnings,
1333 "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.1334 // What it is built with, shown beside `detected` on its settings.
1335 "framework": built.framework,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1336 });
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.1337 if let Some(image) = built.container {
1338 finish["container"] = image;
1339 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91340 for warning in &built.warnings {
1341 log.line(&format!("Warning: {warning}"));
1342 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1343 if let Some(dir) = &built.assets_dir {
1344 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.1345 let assets = collect_site(dir, skip_project)?;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1346 if assets.is_empty() {
1347 bail!("the site to serve is empty");
1348 }
1349 log.line(&format!("Uploading {} files.", assets.len()));
1350 for special in ["_headers", "_redirects"] {
1351 if let Ok(text) = std::fs::read_to_string(dir.join(special)) {
1352 finish["worker"][special] = text.into();
1353 }
1354 }
1355 let manifest: BTreeMap<&str, Value> = assets
1356 .iter()
1357 .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size })))
1358 .collect();
1359 let answer = reporter.send("session", json!({ "manifest": manifest }))?;
1360 if answer["ok"] == false {
1361 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload"));
1362 }
1363 let session: UploadSession =
1364 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?;
1365 let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect();
1366 let mut completion = session.jwt.clone();
1367 for bucket in &session.buckets {
1368 if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? {
1369 completion = jwt;
1370 }
1371 }
1372 finish["completionJwt"] = completion.into();
1373 }
1374 Ok(finish)
1375}
1376
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91377/// A sandbox's build (`MODE=deploy`).
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1378pub fn main() -> i32 {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91379 run(false)
1380}
1381
1382/// `g1t-runner deploy`, from a workflow step: the build in the job's own
1383/// checkout, its log in the step's as well. Exits 1 when the build fails,
1384/// after reporting it, so the step fails too.
1385pub fn cli(args: &[String]) -> i32 {
1386 if args.iter().any(|arg| arg == "--help" || arg == "-h") {
1387 println!("{CLI_HELP}");
1388 return 0;
1389 }
1390 run(true)
1391}
1392
1393const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page.
1394
1395Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds),
1396G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_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.1397BUILD_AS (auto, static, workers or docker), DOCKERFILE, and BUILD_ENV and
1398BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. A container
1399is built with the docker on PATH.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91400
1401g1t-runner detect prints what the build would find, as key=value lines.";
1402
1403fn run(step_log: bool) -> i32 {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1404 let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
1405 (Ok(api), Ok(id), Ok(token)) => Reporter {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91406 base: format!("{}/deployments/jobs/{id}", api.trim_end_matches('/')),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1407 token,
1408 },
1409 _ => {
1410 eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set");
1411 return 2;
1412 }
1413 };
1414 let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
1415 .iter()
1416 .filter_map(|name| std::env::var(name).ok())
1417 .filter(|secret| !secret.is_empty())
1418 .collect();
1419 // The repository's build secrets never appear in the log.
1420 if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) {
1421 secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned));
1422 }
1423 if let Err(error) = reporter.send("started", json!({})) {
1424 eprintln!("g1t-runner: {error:#}");
1425 return 1;
1426 }
1427 let started = Instant::now();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91428 let mut log = Log { echo: step_log.then(|| secrets.clone()), ..Log::default() };
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1429 let outcome = deploy(&reporter, &mut log, &secrets);
1430 let seconds = started.elapsed().as_secs();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91431 if step_log {
1432 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.1433 let _ = std::fs::remove_file(adapter_config_path());
1434 let _ = std::fs::remove_dir_all(docker_config_dir());
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91435 }
1436 let (sent, failed) = match outcome {
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1437 Ok(mut finish) => {
1438 finish["log"] = redact(&log.tail(), &secrets).into();
1439 finish["buildSeconds"] = seconds.into();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91440 (reporter.send("finish", finish), false)
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1441 }
1442 Err(error) => {
1443 let message = redact(&format!("{error:#}"), &secrets);
1444 log.line(&format!("The build failed: {message}"));
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91445 let sent = reporter.send(
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1446 "fail",
1447 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91448 );
1449 (sent, true)
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1450 }
1451 };
1452 match sent {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91453 // A sandbox's build has said how it ended; a step fails with its build.
1454 Ok(answer) if step_log && (failed || answer["ok"] == false || answer["value"] == false) => {
1455 if !failed {
1456 eprintln!("g1t-runner: g1t did not take the build; its deployment says why");
1457 }
1458 1
1459 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1460 Ok(_) => 0,
1461 Err(error) => {
1462 eprintln!("g1t-runner: {error:#}");
1463 1
1464 }
1465 }
1466}
1467
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91468/// `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.1469/// `key=value` lines for `$GITHUB_OUTPUT`: `kind`, `install`, `build`,
1470/// `output-dir`, and `framework` when one is found. Reads `ROOT_DIR`,
1471/// `BUILD_COMMAND` and `OUTPUT_DIR` as the build does; `--root-dir DIR` is
1472/// the same as `ROOT_DIR`. A framework set up to need a server is `none`,
1473/// with what to change on standard error.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91474pub fn detect_cli(args: &[String]) -> i32 {
1475 let mut root = std::env::var("ROOT_DIR").unwrap_or_default();
1476 let mut rest = args.iter();
1477 while let Some(arg) = rest.next() {
1478 match arg.as_str() {
1479 "--root-dir" => root = rest.next().cloned().unwrap_or_default(),
1480 "--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.1481 println!(
1482 "g1t-runner detect [--root-dir DIR]: prints kind, install, build, output-dir and framework (when one is found), and for a container dockerfile and port, as key=value lines. Reads ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR, BUILD_AS (auto, static, workers or docker) and DOCKERFILE as a build does."
1483 );
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91484 return 0;
1485 }
1486 other => {
1487 eprintln!("g1t-runner detect: unknown argument {other}");
1488 return 2;
1489 }
1490 }
1491 }
1492 let dir = project_dir_in(&checkout_dir(), &root);
1493 let custom = std::env::var("BUILD_COMMAND").ok();
1494 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.1495 let build_as = BuildAs::parse(&std::env::var("BUILD_AS").unwrap_or_default());
1496 let dockerfile = std::env::var("DOCKERFILE").ok();
1497 match detect_as(&dir, custom.as_deref(), output.as_deref(), build_as, dockerfile.as_deref()) {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91498 Ok(found) => {
1499 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.1500 if let Some(problem) = &found.problem {
1501 eprintln!("g1t-runner detect: {problem}");
1502 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91503 0
1504 }
1505 Err(error) => {
1506 eprintln!("g1t-runner detect: {error:#}");
1507 1
1508 }
1509 }
1510}
1511
1512/// `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.1513/// there is none. `framework` is left out when none was found, which a
1514/// step's outputs read as empty all the same.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91515fn output_lines(found: &Detected) -> String {
1516 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.1517 let mut lines = format!(
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91518 "kind={}\ninstall={}\nbuild={}\noutput-dir={}\n",
1519 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.1520 one_line(found.install.as_deref()),
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91521 one_line(found.build.as_deref()),
1522 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.1523 );
1524 if let Some(framework) = found.framework_id() {
1525 lines.push_str(&format!("framework={framework}\n"));
1526 }
1527 // A container's Dockerfile (empty when g1t writes one) and its port.
1528 if let Some(container) = &found.container {
1529 lines.push_str(&format!("dockerfile={}\nport={}\n", one_line(container.dockerfile.as_deref()), container.port));
1530 }
1531 lines
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91532}
1533
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537
1538 #[test]
1539 fn the_upload_session_is_read_in_either_spelling() {
1540 for answer in [
1541 json!({ "jwt": "j", "buckets": [["a"]], "upload_url": "https://u" }),
1542 json!({ "jwt": "j", "uploadUrl": "https://u" }),
1543 ] {
1544 let session: UploadSession = serde_json::from_value(answer).unwrap();
1545 assert_eq!(session.upload_url, "https://u");
1546 }
1547 }
1548
1549 #[test]
1550 fn jsonc_comments_and_trailing_commas_are_dropped() {
1551 let text = r#"{
1552 // a comment
1553 "main": "src/index.ts", /* another */
1554 "vars": { "URL": "https://x.dev//not-a-comment", },
1555 }"#;
1556 let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap();
1557 assert_eq!(config.main.as_deref(), Some("src/index.ts"));
1558 assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment");
1559 }
1560
1561 #[test]
1562 fn unsupported_bindings_are_noticed() {
1563 let config: WranglerConfig =
1564 serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap();
1565 assert!(config.rest.contains_key("d1_databases"));
1566 }
1567
1568 #[test]
1569 fn crons_g1t_does_not_run_are_warned_about() {
1570 let config: WranglerConfig =
1571 serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": ["*/5 * * * *", "0 3 * * *"] }, "kv_namespaces": [{ "binding": "KV" }] }"#)
1572 .unwrap();
1573 let warnings = config_warnings(&config);
1574 assert_eq!(warnings.len(), 2);
1575 assert!(warnings.iter().any(|w| w.contains("kv_namespaces")));
1576 assert!(warnings.iter().any(|w| w.contains("triggers.crons") && w.contains("2 schedules")));
1577 let none: WranglerConfig = serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": [] } }"#).unwrap();
1578 assert!(config_warnings(&none).is_empty());
1579 let toml: WranglerConfig = toml::from_str("main = \"a.js\"
1580[triggers]
1581crons = [\"0 * * * *\"]
1582").unwrap();
1583 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.1584 // A config a build wrote lists every kind of binding, mostly empty.
1585 let written: WranglerConfig = serde_json::from_str(
1586 r#"{ "main": "entry.mjs", "d1_databases": [], "durable_objects": { "bindings": [] }, "triggers": {}, "kv_namespaces": [{ "binding": "SESSION" }] }"#,
1587 )
1588 .unwrap();
1589 assert_eq!(config_warnings(&written).len(), 1);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1590 }
1591
1592 #[test]
1593 fn a_site_is_plain_html_only_when_its_own_files_are_served_unbuilt() {
1594 let project = Path::new("/w/site");
1595 assert_eq!(static_kind(project, project, false), "html");
1596 assert_eq!(static_kind(project, project, true), "static");
1597 assert_eq!(static_kind(project, &project.join("dist"), false), "static");
1598 }
1599
1600 #[test]
1601 fn files_are_typed_by_extension() {
1602 assert_eq!(content_type("/index.HTML"), "text/html");
1603 assert_eq!(content_type("/a/b.woff2"), "font/woff2");
1604 assert_eq!(content_type("/LICENSE"), "application/octet-stream");
1605 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91606
1607 /// A fresh directory with `files` (path, contents) in it.
1608 fn project(name: &str, files: &[(&str, &str)]) -> PathBuf {
1609 let dir = std::env::temp_dir().join(format!("g1t-detect-{name}-{}", std::process::id()));
1610 let _ = std::fs::remove_dir_all(&dir);
1611 for (path, text) in files {
1612 let path = dir.join(path);
1613 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1614 std::fs::write(path, text).unwrap();
1615 }
1616 std::fs::create_dir_all(&dir).unwrap();
1617 dir
1618 }
1619
1620 #[test]
1621 fn detect_finds_a_workers_project_and_how_it_builds() {
1622 let dir = project(
1623 "workers",
1624 &[
1625 ("wrangler.jsonc", r#"{ "main": "src/index.ts", "assets": { "directory": "./public" }, }"#),
1626 ("package.json", "{}"),
1627 ("pnpm-lock.yaml", ""),
1628 ],
1629 );
1630 let found = detect(&dir, None, None).unwrap();
1631 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.1632 assert_eq!(found.install.as_deref(), Some("corepack enable && pnpm install --frozen-lockfile"));
1633 assert_eq!(found.framework, None);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91634 assert_eq!(found.build.as_deref(), Some("npx --yes wrangler@4 deploy --dry-run"));
1635 assert_eq!(found.output_dir.as_deref(), Some("./public"));
1636 // The project's own build command runs first.
1637 let custom = detect(&dir, Some("npm run codegen"), None).unwrap();
1638 assert_eq!(custom.build.as_deref(), Some("npm run codegen && npx --yes wrangler@4 deploy --dry-run"));
1639 }
1640
1641 #[test]
1642 fn detect_finds_a_static_build_and_a_plain_site() {
1643 let built = project("static", &[("package.json", r#"{ "scripts": { "build": "vite build" } }"#), ("package-lock.json", "{}")]);
1644 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.1645 assert_eq!(found.kind, "static");
1646 assert_eq!(found.install.as_deref(), Some("npm ci"));
1647 assert_eq!(found.build.as_deref(), Some("npm run build"));
1648 assert_eq!(found.output_dir, None, "its output is found once it has built");
1649 assert_eq!(found.framework, None, "a build script alone names no framework");
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91650 std::fs::create_dir_all(built.join("dist")).unwrap();
1651 std::fs::write(built.join("dist/index.html"), "<h1>hi</h1>").unwrap();
1652 assert_eq!(detect(&built, None, None).unwrap().output_dir.as_deref(), Some("dist"));
1653 assert_eq!(detect(&built, None, Some("/site/")).unwrap().output_dir.as_deref(), Some("site"));
1654
1655 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.1656 let found = detect(&html, None, None).unwrap();
1657 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-7nm8r91658 // A `public` folder alone, without an index.html, is not a site.
1659 let empty = project("empty", &[("public/robots.txt", ""), ("README.md", "")]);
1660 assert_eq!(detect(&empty, None, None).unwrap().kind, "none");
1661 }
1662
1663 #[test]
1664 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.1665 let mut found = Detected {
1666 kind: "static",
1667 framework: None,
1668 install: Some("npm ci".into()),
1669 build: Some("a\nb".into()),
1670 output_dir: None,
1671 problem: None,
1672 pre_build: None,
1673 container: None,
1674 fallback: None,
1675 };
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91676 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.1677 let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]);
1678 found.framework = frameworks::detect(&vite, false);
1679 assert!(output_lines(&found).ends_with("output-dir=\nframework=vite\n"));
1680 }
1681
1682 /// One project of the table below: its files, the project's own
1683 /// settings, and what detect should say.
1684 struct Case {
1685 name: &'static str,
1686 files: &'static [(&'static str, &'static str)],
1687 custom_build: Option<&'static str>,
1688 chosen_output: Option<&'static str>,
1689 kind: &'static str,
1690 framework: Option<&'static str>,
1691 build: Option<&'static str>,
1692 output_dir: Option<&'static str>,
1693 /// A phrase the problem says, if there is one.
1694 problem: Option<&'static str>,
1695 }
1696
1697 const CASE: Case = Case {
1698 name: "",
1699 files: &[],
1700 custom_build: None,
1701 chosen_output: None,
1702 kind: "static",
1703 framework: None,
1704 build: Some("npm run build"),
1705 output_dir: None,
1706 problem: None,
1707 };
1708
1709 #[test]
1710 fn detect_knows_each_framework() {
1711 let cases = [
1712 Case {
1713 name: "vite",
1714 files: &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)],
1715 framework: Some("vite"),
1716 build: Some("npx vite build"),
1717 output_dir: Some("dist"),
1718 ..CASE
1719 },
1720 Case {
1721 name: "vite-react",
1722 files: &[
1723 ("package.json", r#"{ "scripts": { "build": "tsc && vite build" }, "dependencies": { "react": "19" }, "devDependencies": { "vite": "7", "@vitejs/plugin-react": "5" } }"#),
1724 ("vite.config.ts", "export default defineConfig({ plugins: [react()], build: { outDir: 'site' } })"),
1725 ],
1726 framework: Some("vite-react"),
1727 output_dir: Some("site"),
1728 ..CASE
1729 },
1730 Case {
1731 name: "vite-vue",
1732 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7", "@vitejs/plugin-vue": "6" } }"#)],
1733 framework: Some("vite-vue"),
1734 output_dir: Some("dist"),
1735 ..CASE
1736 },
1737 Case {
1738 name: "vite-svelte",
1739 files: &[("package.json", r#"{ "devDependencies": { "vite": "7", "@sveltejs/vite-plugin-svelte": "6" } }"#)],
1740 framework: Some("vite-svelte"),
1741 build: Some("npx vite build"),
1742 output_dir: Some("dist"),
1743 ..CASE
1744 },
1745 Case {
1746 name: "vite-solid",
1747 files: &[("package.json", r#"{ "devDependencies": { "vite": "7", "vite-plugin-solid": "2" } }"#)],
1748 framework: Some("vite-solid"),
1749 build: Some("npx vite build"),
1750 output_dir: Some("dist"),
1751 ..CASE
1752 },
1753 Case {
1754 name: "vite-cloudflare",
1755 files: &[
1756 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7", "@cloudflare/vite-plugin": "1" } }"#),
1757 ("wrangler.jsonc", r#"{ "main": "worker/index.ts" }"#),
1758 ],
1759 kind: "workers",
1760 framework: Some("vite"),
1761 build: Some("npm run build && npx --yes wrangler@4 deploy --dry-run"),
1762 ..CASE
1763 },
1764 Case {
1765 name: "astro",
1766 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5" } }"#), ("astro.config.mjs", "export default defineConfig({ site: 'https://x.dev' })")],
1767 framework: Some("astro"),
1768 output_dir: Some("dist"),
1769 ..CASE
1770 },
1771 Case {
1772 name: "astro-server",
1773 files: &[("package.json", r#"{ "dependencies": { "astro": "5", "@astrojs/node": "9" } }"#), ("astro.config.mjs", "export default { output: \"server\" }")],
1774 kind: "container",
1775 framework: Some("astro"),
1776 build: Some(GENERATED),
1777 ..CASE
1778 },
1779 Case {
1780 name: "astro-cloudflare",
1781 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5", "@astrojs/cloudflare": "12" } }"#)],
1782 kind: "workers",
1783 framework: Some("astro"),
1784 ..CASE
1785 },
1786 Case {
1787 name: "astro-cloudflare-folder",
1788 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5", "@astrojs/cloudflare": "12" } }"#)],
1789 chosen_output: Some("dist"),
1790 framework: Some("astro"),
1791 output_dir: Some("dist"),
1792 ..CASE
1793 },
1794 Case {
1795 name: "next-export",
1796 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.ts", "const config: NextConfig = {\n output: 'export',\n};")],
1797 framework: Some("nextjs"),
1798 output_dir: Some("out"),
1799 ..CASE
1800 },
1801 Case {
1802 name: "next-server",
1803 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.mjs", "export default {}")],
1804 kind: "container",
1805 framework: Some("nextjs"),
1806 build: Some(GENERATED),
1807 ..CASE
1808 },
1809 Case {
1810 name: "next-server-own-folder",
1811 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#)],
1812 chosen_output: Some("out"),
1813 framework: Some("nextjs"),
1814 output_dir: Some("out"),
1815 ..CASE
1816 },
1817 Case {
1818 name: "opennext",
1819 files: &[
1820 ("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#),
1821 ("wrangler.jsonc", r#"{ "main": ".open-next/worker.js", "assets": { "directory": ".open-next/assets", "binding": "ASSETS" } }"#),
1822 ],
1823 kind: "workers",
1824 framework: Some("nextjs"),
1825 build: Some("npx opennextjs-cloudflare build && npx --yes wrangler@4 deploy --dry-run"),
1826 output_dir: Some(".open-next/assets"),
1827 ..CASE
1828 },
1829 Case {
1830 name: "opennext-no-config",
1831 files: &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)],
1832 kind: "none",
1833 framework: Some("nextjs"),
1834 build: None,
1835 problem: Some("`.open-next/worker.js`"),
1836 ..CASE
1837 },
1838 Case {
1839 name: "sveltekit-static",
1840 files: &[
1841 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-static": "3", "vite": "7" } }"#),
1842 ("svelte.config.js", "import adapter from '@sveltejs/adapter-static';\nexport default { kit: { adapter: adapter() } };"),
1843 ],
1844 framework: Some("sveltekit"),
1845 output_dir: Some("build"),
1846 ..CASE
1847 },
1848 Case {
1849 name: "sveltekit-3-static",
1850 files: &[
1851 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "3", "@sveltejs/adapter-static": "4", "@sveltejs/adapter-cloudflare": "8" } }"#),
1852 ("vite.config.js", "import adapter from '@sveltejs/adapter-static';\nexport default defineConfig({ plugins: [sveltekit({ adapter: adapter({ pages: 'www' }) })] });"),
1853 ],
1854 framework: Some("sveltekit"),
1855 output_dir: Some("www"),
1856 ..CASE
1857 },
1858 Case {
1859 name: "sveltekit-cloudflare",
1860 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-cloudflare": "7" } }"#)],
1861 kind: "workers",
1862 framework: Some("sveltekit"),
1863 ..CASE
1864 },
1865 Case {
1866 name: "sveltekit-auto",
1867 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-auto": "6" } }"#)],
1868 kind: "none",
1869 framework: Some("sveltekit"),
1870 build: None,
1871 problem: Some("Or add a `Dockerfile`"),
1872 ..CASE
1873 },
1874 Case {
1875 name: "nuxt",
1876 files: &[("package.json", r#"{ "scripts": { "build": "nuxt build", "generate": "nuxt generate" }, "dependencies": { "nuxt": "4" } }"#)],
1877 framework: Some("nuxt"),
1878 build: Some("npm run generate"),
1879 output_dir: Some(".output/public"),
1880 ..CASE
1881 },
1882 Case {
1883 name: "nuxt-no-generate",
1884 files: &[("package.json", r#"{ "scripts": { "build": "nuxt build" }, "dependencies": { "nuxt": "4" } }"#)],
1885 framework: Some("nuxt"),
1886 build: Some("npx nuxi generate"),
1887 output_dir: Some(".output/public"),
1888 ..CASE
1889 },
1890 Case {
1891 name: "react-router-spa",
1892 files: &[
1893 ("package.json", r#"{ "scripts": { "build": "react-router build" }, "devDependencies": { "@react-router/dev": "7", "vite": "7" } }"#),
1894 ("react-router.config.ts", "export default {\n // Server-side render by default\n ssr: false,\n} satisfies Config;"),
1895 ],
1896 framework: Some("react-router"),
1897 output_dir: Some("build/client"),
1898 ..CASE
1899 },
1900 Case {
1901 name: "react-router-ssr",
1902 files: &[("package.json", r#"{ "devDependencies": { "@react-router/dev": "7" } }"#), ("react-router.config.ts", "export default { ssr: true }")],
1903 kind: "container",
1904 framework: Some("react-router"),
1905 build: Some(GENERATED),
1906 ..CASE
1907 },
1908 Case {
1909 name: "remix-spa",
1910 files: &[
1911 ("package.json", r#"{ "scripts": { "build": "remix vite:build" }, "devDependencies": { "@remix-run/dev": "2", "vite": "5" } }"#),
1912 ("vite.config.ts", "export default defineConfig({ plugins: [remix({ ssr: false })] })"),
1913 ],
1914 framework: Some("remix"),
1915 output_dir: Some("build/client"),
1916 ..CASE
1917 },
1918 Case {
1919 name: "gatsby",
1920 files: &[("package.json", r#"{ "scripts": { "build": "gatsby build" }, "dependencies": { "gatsby": "5" } }"#)],
1921 framework: Some("gatsby"),
1922 output_dir: Some("public"),
1923 ..CASE
1924 },
1925 Case {
1926 name: "docusaurus",
1927 files: &[("package.json", r#"{ "scripts": { "build": "docusaurus build" }, "dependencies": { "@docusaurus/core": "3" } }"#)],
1928 framework: Some("docusaurus"),
1929 output_dir: Some("build"),
1930 ..CASE
1931 },
1932 Case {
1933 name: "eleventy",
1934 files: &[("package.json", r#"{ "devDependencies": { "@11ty/eleventy": "3" } }"#)],
1935 framework: Some("eleventy"),
1936 build: Some("npx @11ty/eleventy"),
1937 output_dir: Some("_site"),
1938 ..CASE
1939 },
1940 Case {
1941 name: "angular",
1942 files: &[
1943 ("package.json", r#"{ "scripts": { "build": "ng build" }, "dependencies": { "@angular/core": "20" } }"#),
1944 ("angular.json", r#"{ "projects": { "shop": { "projectType": "application", "architect": { "build": { "builder": "@angular/build:application", "options": { "outputPath": "dist/shop" } } } } } }"#),
1945 ],
1946 framework: Some("angular"),
1947 output_dir: Some("dist/shop/browser"),
1948 ..CASE
1949 },
1950 Case {
1951 name: "angular-default",
1952 files: &[
1953 ("package.json", r#"{ "dependencies": { "@angular/core": "20" } }"#),
1954 ("angular.json", r#"{ "projects": { "app": { "projectType": "application", "architect": { "build": { "builder": "@angular/build:application", "options": {} } } } } }"#),
1955 ],
1956 framework: Some("angular"),
1957 build: Some("npx ng build"),
1958 output_dir: Some("dist/app/browser"),
1959 ..CASE
1960 },
1961 Case {
1962 name: "hugo",
1963 files: &[("hugo.toml", "baseURL = 'https://example.org/'\ntitle = 'Site'\n")],
1964 framework: Some("hugo"),
1965 build: Some(frameworks_hugo()),
1966 output_dir: Some("public"),
1967 ..CASE
1968 },
1969 Case {
1970 name: "hugo-config-toml",
1971 files: &[("config.toml", "baseURL = \"https://example.org/\"\npublishDir = \"site\"\n")],
1972 framework: Some("hugo"),
1973 build: Some(frameworks_hugo()),
1974 output_dir: Some("site"),
1975 ..CASE
1976 },
1977 Case {
1978 name: "not-hugo",
1979 files: &[("config.toml", "base_url = \"https://example.org\"\n"), ("index.html", "")],
1980 kind: "html",
1981 build: None,
1982 output_dir: Some("."),
1983 ..CASE
1984 },
1985 Case {
1986 name: "jekyll",
1987 files: &[("_config.yml", "title: Blog\n"), ("Gemfile", "gem 'jekyll'\n")],
1988 framework: Some("jekyll"),
1989 build: Some("bundle exec jekyll build"),
1990 output_dir: Some("_site"),
1991 ..CASE
1992 },
1993 Case {
1994 name: "mdbook",
1995 files: &[("book.toml", "[book]\ntitle = \"Guide\"\n")],
1996 framework: Some("mdbook"),
1997 build: Some(frameworks_mdbook()),
1998 output_dir: Some("book"),
1999 ..CASE
2000 },
2001 Case {
2002 name: "mdbook-build-dir",
2003 files: &[("book.toml", "[book]\ntitle = \"Guide\"\n[build]\nbuild-dir = \"site\"\n")],
2004 framework: Some("mdbook"),
2005 build: Some(frameworks_mdbook()),
2006 output_dir: Some("site"),
2007 ..CASE
2008 },
2009 // The project's own settings win.
2010 Case {
2011 name: "own-settings",
2012 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7" } }"#)],
2013 custom_build: Some("make site"),
2014 chosen_output: Some("www"),
2015 framework: Some("vite"),
2016 build: Some("make site"),
2017 output_dir: Some("www"),
2018 ..CASE
2019 },
2020 ];
2021 for case in cases {
2022 let dir = project(&format!("fw-{}", case.name), case.files);
2023 let found = detect(&dir, case.custom_build, case.chosen_output).unwrap();
2024 let name = case.name;
2025 assert_eq!(found.kind, case.kind, "{name}: kind");
2026 assert_eq!(found.framework.as_ref().map(|f| f.id), case.framework, "{name}: framework");
2027 assert_eq!(found.build.as_deref(), case.build, "{name}: build");
2028 assert_eq!(found.output_dir.as_deref(), case.output_dir, "{name}: output-dir");
2029 match case.problem {
2030 Some(phrase) => assert!(found.problem.as_deref().is_some_and(|p| p.contains(phrase)), "{name}: {:?}", found.problem),
2031 None => assert_eq!(found.problem, None, "{name}: problem"),
2032 }
2033 let _ = std::fs::remove_dir_all(&dir);
2034 }
2035 }
2036
2037 #[test]
2038 fn build_as_and_dockerfiles_decide_in_order() {
2039 let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap();
2040 // A framework that builds a static site stays one beside a
2041 // Dockerfile: it costs nothing while no one visits.
2042 let vite = project("p-vite", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", "FROM nginx\nEXPOSE 80\n")]);
2043 assert_eq!(detect_with(&vite, BuildAs::Auto, None).kind, "static");
2044 // Unless the project says Docker.
2045 let found = detect_with(&vite, BuildAs::Docker, None);
2046 assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
2047 let container = found.container.unwrap();
2048 assert_eq!((container.dockerfile.as_deref(), container.port), (Some("Dockerfile"), 80));
2049 // A Wrangler config wins in Auto, and not when it says Static site.
2050 let workers = project("p-workers", &[("wrangler.jsonc", r#"{ "assets": { "directory": "public" } }"#), ("index.html", "")]);
2051 assert_eq!(detect_with(&workers, BuildAs::Auto, None).kind, "workers");
2052 assert_eq!(detect_with(&workers, BuildAs::Static, None).kind, "html");
2053 // Workers, without a config or adapter, says what is missing.
2054 let html = project("p-html", &[("index.html", "<h1>hi</h1>")]);
2055 let found = detect_with(&html, BuildAs::Workers, None);
2056 assert_eq!(found.kind, "none");
2057 assert!(found.problem.unwrap().contains("no Wrangler config"));
2058 // No framework and a Dockerfile: a container, built as the site it
2059 // is without one while g1t.page runs no containers.
2060 let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM nginx")]);
2061 let found = detect_with(&both, BuildAs::Auto, None);
2062 assert_eq!((found.kind, found.framework_id()), ("container", Some("dockerfile")));
2063 assert_eq!(found.container.as_ref().unwrap().port, 8080);
2064 assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("html"));
2065 // A Dockerfile at a path of the project's choosing.
2066 let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]);
2067 assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none");
2068 let found = detect_with(&chosen, BuildAs::Auto, Some("ops/web.Dockerfile"));
2069 assert_eq!((found.kind, found.container.unwrap().port), ("container", 3000));
2070 assert!(detect_as(&chosen, None, None, BuildAs::Auto, Some("nope")).is_err());
2071 // A server g1t recognises gets a Dockerfile written for it.
2072 let express = project("p-express", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]);
2073 let found = detect_with(&express, BuildAs::Auto, None);
2074 assert_eq!((found.kind, found.framework_id(), found.install.as_deref()), ("container", Some("node"), None));
2075 assert!(found.fallback.is_none());
2076 assert!(output_lines(&found).ends_with("framework=node\ndockerfile=\nport=8080\n"));
2077 // Nothing at all says to add a Dockerfile.
2078 let nothing = project("p-nothing", &[("README.md", "")]);
2079 let found = detect_with(&nothing, BuildAs::Auto, None);
2080 assert_eq!(found.kind, "none");
2081 assert!(found.problem.as_deref().unwrap().starts_with("found nothing to serve"));
2082 assert!(found.problem.unwrap().contains("Add a `Dockerfile`"));
2083 let found = detect_with(&nothing, BuildAs::Docker, None);
2084 assert!(found.problem.unwrap().contains("**Build as** is Docker"));
2085 // A framework set up for a server, with a Dockerfile of its own.
2086 let next = project("p-next", &[("package.json", r#"{ "dependencies": { "next": "16" } }"#), ("Dockerfile", "FROM node")]);
2087 let found = detect_with(&next, BuildAs::Auto, None);
2088 assert_eq!((found.kind, found.framework_id()), ("container", Some("nextjs")));
2089 assert_eq!(found.container.unwrap().dockerfile.as_deref(), Some("Dockerfile"));
2090 // OpenNext's adapter means Workers: it is not run as a server.
2091 let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]);
2092 assert_eq!(detect_with(&opennext, BuildAs::Auto, None).kind, "none");
2093 for dir in [vite, workers, html, both, chosen, express, nothing, next, opennext] {
2094 let _ = std::fs::remove_dir_all(dir);
2095 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r92096 }
2097
2098 #[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.2099 fn the_pushed_digest_is_the_one_in_the_apps_repository() {
2100 let digest = "a".repeat(64);
2101 let lines = format!("docker.io/x@sha256:{digest}\nregistry.cloudflare.com/acc/other@sha256:{digest}\nregistry.cloudflare.com/acc/g1t-web@sha256:{digest}\n");
2102 assert_eq!(
2103 pushed_digest(&lines, "registry.cloudflare.com", "acc/g1t-web"),
2104 Some(format!("registry.cloudflare.com/acc/g1t-web@sha256:{digest}"))
2105 );
2106 assert_eq!(pushed_digest("registry.cloudflare.com/acc/g1t-web@sha256:short", "registry.cloudflare.com", "acc/g1t-web"), None);
2107 let grant: RegistryGrant = serde_json::from_value(json!({
2108 "registry": "registry.cloudflare.com", "repository": "acc/g1t-web", "tag": "dpl_1",
2109 "username": "u", "password": "p", "max_image_bytes": 2_000_000_000u64, "instance_type": "lite"
2110 }))
2111 .unwrap();
2112 assert_eq!((grant.max_image_bytes, grant.instance_type.as_deref()), (2_000_000_000, Some("lite")));
2113 assert_eq!(size_text(1_450_000_000), "1.4 GB");
2114 }
2115
2116 /// What a container g1t writes the Dockerfile for is built with.
2117 const GENERATED: &str = "docker build -f Dockerfile (written by g1t) .";
2118
2119 fn frameworks_hugo() -> &'static str {
2120 "if command -v hugo >/dev/null 2>&1; then hugo --minify; else npx --yes hugo-extended@0.166.0 --minify; fi"
2121 }
2122
2123 fn frameworks_mdbook() -> &'static str {
2124 "if ! command -v mdbook >/dev/null 2>&1; then cargo install mdbook --version 0.5.4 --locked --quiet; fi && mdbook build"
2125 }
2126
2127 #[test]
2128 fn jekyll_installs_its_gems_after_the_packages() {
2129 let dir = project("jekyll-install", &[("_config.yml", ""), ("Gemfile", ""), ("package.json", "{}")]);
2130 let found = detect(&dir, None, None).unwrap();
2131 assert_eq!(found.install.as_deref(), Some("npm install && bundle config set --local path vendor/bundle && bundle install"));
2132 }
2133
2134 #[test]
2135 fn an_adapters_worker_is_found_once_built_and_never_served() {
2136 let dir = project(
2137 "adapter",
2138 &[
2139 ("dist/_worker.js/index.js", "export default {}"),
2140 ("dist/_routes.json", "{}"),
2141 ("dist/.assetsignore", "_worker.js\nsecret.txt\n"),
2142 ("dist/secret.txt", "no"),
2143 ("dist/index.html", "<h1>hi</h1>"),
2144 ("dist/about/_worker.js", "a page's own file"),
2145 ("kit/.svelte-kit/cloudflare/_worker.js", "export default {}"),
2146 ],
2147 );
2148 let astro = Adapter { entry: "dist/_worker.js", assets: "dist" };
2149 assert_eq!(adapter_entry(&dir, &astro), Some(dir.join("dist/_worker.js/index.js")));
2150 let kit = Adapter { entry: ".svelte-kit/cloudflare/_worker.js", assets: ".svelte-kit/cloudflare" };
2151 assert_eq!(adapter_entry(&dir.join("kit"), &kit), Some(dir.join("kit/.svelte-kit/cloudflare/_worker.js")));
2152 assert_eq!(adapter_entry(&dir.join("missing"), &kit), None);
2153 let mut served: Vec<String> = collect_site(&dir.join("dist"), false).unwrap().into_iter().map(|a| a.path).collect();
2154 served.sort();
2155 assert_eq!(served, ["/about/_worker.js", "/index.html"]);
2156 }
2157
2158 #[test]
2159 fn a_config_the_build_wrote_is_read_in_place_of_the_projects() {
2160 let dir = project(
2161 "redirected",
2162 &[
2163 ("wrangler.jsonc", r#"{ "main": "worker/index.ts" }"#),
2164 (".wrangler/deploy/config.json", r#"{ "configPath": "../../dist/app/wrangler.json" }"#),
2165 ("dist/app/wrangler.json", r#"{ "main": "index.js", "assets": { "directory": "../client" } }"#),
2166 ],
2167 );
2168 let (config, base) = redirected_config(&dir).unwrap().unwrap();
2169 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.2170 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.2171 assert!(base.join(config.assets.unwrap().directory.unwrap()).ends_with("dist/app/../client"));
2172 assert!(redirected_config(&project("not-redirected", &[])).unwrap().is_none());
2173 }
2174
2175 #[test]
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r92176 fn the_root_directory_stays_inside_the_checkout() {
2177 let base = Path::new("/w/repo");
2178 assert_eq!(project_dir_in(base, ""), base);
2179 assert_eq!(project_dir_in(base, "."), base);
2180 assert_eq!(project_dir_in(base, "/apps/site/"), base.join("apps/site"));
2181 assert_eq!(project_dir_in(base, "../etc"), base);
2182 assert_eq!(project_dir_in(base, "apps/../../x"), base);
2183 }
2184
2185 #[test]
2186 fn a_step_prints_the_log_redacted() {
2187 let mut log = Log { echo: Some(vec!["hunter22".to_owned()]), ..Log::default() };
2188 log.line("token hunter22 used");
2189 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.2190 assert_eq!(wrangler_build(Some(Path::new("/tmp/b")), None), "npx --yes wrangler@4 deploy --dry-run --outdir /tmp/b");
2191 assert_eq!(
2192 wrangler_build(Some(Path::new("/tmp/b")), Some(Path::new("/tmp/w.json"))),
2193 "npx --yes wrangler@4 deploy --dry-run --config /tmp/w.json --outdir /tmp/b"
2194 );
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r92195 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor2196}

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