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

Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1//! 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.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put33//! - 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//!
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put41//! 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.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put60
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball 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};
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put62use 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.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put82const 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.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put120#[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>>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put125}
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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put134 }
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<()> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put350 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 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put361 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)?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put363 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>)> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put448 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)?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put455 for file in files {
456 let name = file
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9457 .strip_prefix(bundle)?
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put458 .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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily498/// 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 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily512 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) {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily515 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
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put533/// 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>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look538 /// 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`.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look541 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>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put547}
548
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look549/// 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);
579 let path = path.canonicalize().unwrap_or(path);
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
585/// The entry of the Worker a framework's adapter wrote, once it has built:
586/// the file, or a directory's `index.js`.
587fn adapter_entry(dir: &Path, adapter: &Adapter) -> Option<PathBuf> {
588 let entry = dir.join(adapter.entry);
589 if entry.is_file() {
590 return Some(entry);
591 }
592 ["index.js", "index.mjs"].iter().map(|name| entry.join(name)).find(|index| index.is_file())
593}
594
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9595/// The first of the usual output directories a static build wrote (or the
596/// project's own `index.html`), relative to the project: `.` for itself.
597fn find_output(dir: &Path) -> Option<String> {
598 OUTPUT_DIRS
599 .iter()
600 .find(|name| {
601 let path = dir.join(name);
602 path.join("index.html").exists() || (path.is_dir() && **name != "public")
603 })
604 .map(|name| (*name).to_owned())
605 .or_else(|| dir.join("index.html").exists().then(|| ".".to_owned()))
606}
607
608/// What a build of the project at `dir` does, found before anything runs:
609/// what it is, how its dependencies are installed, what builds it, and
610/// where what it serves is. For a site with a build of its own, the output
611/// 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.612/// unless one was chosen or the framework says where it writes.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9613#[derive(Debug, PartialEq, Eq)]
614pub(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.615 /// `workers`, `static`, `html`, `container`, or `none` when there is
616 /// nothing to serve.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9617 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.618 /// The framework it is built with, if g1t knows it.
619 pub framework: Option<Framework>,
620 pub install: Option<String>,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9621 pub build: Option<String>,
622 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.623 /// Why it cannot be served as it is set up, and what to change: the
624 /// kind is then `none`.
625 pub problem: Option<String>,
626 /// What runs before wrangler bundles a Workers project; for anything
627 /// else, `build`.
628 pre_build: Option<String>,
629 /// For a container: its Dockerfile, or the one g1t writes.
630 pub container: Option<ContainerBuild>,
631 /// What it would be served as without its Dockerfile, when it is a
632 /// container only for having one: built so while g1t.page runs no
633 /// containers (`not_enabled` from the registry step).
634 fallback: Option<Box<Detected>>,
635}
636
637/// How a container is built.
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub(crate) struct ContainerBuild {
640 /// The project's own Dockerfile, relative to the project; none when
641 /// g1t writes one.
642 pub dockerfile: Option<String>,
643 /// The Dockerfile g1t writes, for a server it recognises.
644 pub generated: Option<container::Server>,
645 /// What it listens on: its Dockerfile's `EXPOSE`, else 8080. `PORT`
646 /// is set to it.
647 pub port: u16,
648}
649
650impl Detected {
651 fn nothing(framework: Option<Framework>, install: Option<String>, problem: String) -> Detected {
652 Detected {
653 kind: "none",
654 framework,
655 install,
656 build: None,
657 output_dir: None,
658 problem: Some(problem),
659 pre_build: None,
660 container: None,
661 fallback: None,
662 }
663 }
664
665 /// The `framework` `g1t-runner detect` prints: the framework, else for
666 /// a container the server g1t wrote a Dockerfile for (`flask`…) or
667 /// `dockerfile`.
668 pub(crate) fn framework_id(&self) -> Option<&'static str> {
669 self.framework.as_ref().map(|f| f.id).or_else(|| {
670 self.container.as_ref().map(|c| c.generated.as_ref().map_or("dockerfile", |server| server.id))
671 })
672 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9673}
674
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.675/// Why nothing could be found to serve, and what to add.
676const NOTHING_TO_SERVE: &str = "found nothing to serve: no Workers config, no Dockerfile, no index.html, and none of dist, build, out, \
677 public, _site or .output/public. Add a `Dockerfile` to deploy it as a container, or set **Build command** and \
678 **Output directory** for a static site.";
679
680/// The project as a container: its own Dockerfile, else one g1t writes
681/// for `framework` or for a server it recognises. None when it has neither.
682fn as_container(dir: &Path, framework: Option<Framework>, own: Option<String>) -> Option<Detected> {
683 let build = match own {
684 Some(path) => {
685 let text = std::fs::read_to_string(dir.join(&path)).unwrap_or_default();
686 ContainerBuild { port: container::exposed_port(&text).unwrap_or(container::DEFAULT_PORT), dockerfile: Some(path), generated: None }
687 }
688 None => {
689 let server = match &framework {
690 // One set up for Workers (OpenNext's adapter) is not run as a server.
691 Some(framework) if framework.workers => None,
692 Some(framework) => container::for_framework(dir, framework.id),
693 None => container::server(dir),
694 }?;
695 ContainerBuild { dockerfile: None, generated: Some(server), port: container::DEFAULT_PORT }
696 }
697 };
698 let file = build.dockerfile.clone().unwrap_or_else(|| "Dockerfile (written by g1t)".to_owned());
699 Some(Detected {
700 kind: "container",
701 framework,
702 // The image installs what it needs: nothing runs before its build.
703 install: None,
704 build: Some(format!("docker build -f {file} .")),
705 output_dir: None,
706 problem: None,
707 pre_build: None,
708 container: Some(build),
709 fallback: None,
710 })
711}
712
713#[cfg(test)]
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9714pub(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.715 detect_as(dir, custom_build, chosen_output, BuildAs::Auto, None)
716}
717
718/// What the project is, by its **Build as** setting (`build_as`) and its
719/// Dockerfile's path (`dockerfile`, when it chose one):
720///
721/// - **Docker**: a container, from its Dockerfile or one g1t writes.
722/// - **Static site**: built and served as files, whatever else it has.
723/// - **Workers**: its Wrangler config or a framework adapter's Worker.
724/// - **Auto**: a Wrangler config is a Workers project; a framework that
725/// builds a static site or a Worker is that, which costs nothing while
726/// no one visits; a framework set up to need a server is a container
727/// when it has a Dockerfile, or when g1t can write one; a project with
728/// no framework g1t knows is a container when it has a Dockerfile or is
729/// a server g1t recognises (a Node.js, Python, Go, Ruby or PHP app),
730/// else a static site, else nothing, saying to add a Dockerfile.
731pub(crate) fn detect_as(
732 dir: &Path,
733 custom_build: Option<&str>,
734 chosen_output: Option<&str>,
735 build_as: BuildAs,
736 dockerfile: Option<&str>,
737) -> Result<Detected> {
738 let own = container::own_dockerfile(dir, dockerfile)?;
739 match build_as {
740 BuildAs::Docker => {
741 let framework = frameworks::detect(dir, read_wrangler(dir)?.is_some());
742 Ok(as_container(dir, framework.clone(), own).unwrap_or_else(|| {
743 Detected::nothing(
744 framework,
745 None,
746 "**Build as** is Docker, but this project has no Dockerfile and g1t doesn't recognise a server it can write \
747 one for. Add a `Dockerfile` (or set its path under Settings, Deployments)."
748 .to_owned(),
749 )
750 }))
751 }
752 BuildAs::Static => detect_site(dir, custom_build, chosen_output, true),
753 BuildAs::Workers => {
754 let site = detect_site(dir, custom_build, chosen_output, false)?;
755 if site.kind == "workers" {
756 return Ok(site);
757 }
758 Ok(Detected::nothing(
759 site.framework,
760 site.install,
761 "**Build as** is Workers, but this project has no Wrangler config (`wrangler.jsonc`, `wrangler.json` or \
762 `wrangler.toml`) and no framework adapter that writes a Worker."
763 .to_owned(),
764 ))
765 }
766 BuildAs::Auto => {
767 let site = detect_site(dir, custom_build, chosen_output, false)?;
768 if site.kind == "workers" || (site.framework.is_some() && site.problem.is_none()) {
769 return Ok(site);
770 }
771 if let Some(problem) = site.problem.clone() {
772 // A framework set up to need a server.
773 let found = as_container(dir, site.framework.clone(), own);
774 return Ok(found.unwrap_or_else(|| {
775 Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container."))
776 }));
777 }
778 let has_own = own.is_some();
779 if let Some(mut found) = as_container(dir, None, own) {
780 // A Dockerfile beside a site that could be served as it is.
781 if has_own && (site.kind == "static" || site.kind == "html") {
782 found.fallback = Some(Box::new(site));
783 }
784 return Ok(found);
785 }
786 if site.kind == "none" {
787 return Ok(Detected::nothing(site.framework, site.install, NOTHING_TO_SERVE.to_owned()));
788 }
789 Ok(site)
790 }
791 }
792}
793
794/// What the project is as a static site or Workers project: `as_static`
795/// ignores its Wrangler config and any framework adapter, and builds it
796/// even when its framework is set up to need a server.
797fn 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-7nm8r9798 let custom_build = custom_build.map(str::trim).filter(|c| !c.is_empty());
799 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.800 let config = if as_static { None } else { read_wrangler(dir)? };
801 let framework = frameworks::detect(dir, config.is_some());
802 let installs: Vec<&str> = [install_command(dir), framework.as_ref().and_then(|f| f.install)].into_iter().flatten().collect();
803 let install = (!installs.is_empty()).then(|| installs.join(" && "));
804 let framework_build = framework.as_ref().and_then(|f| f.build.clone());
805 if let Some(config) = config {
806 // A framework built for Workers builds before wrangler bundles what
807 // it wrote; any other project's config builds it, as it always has.
808 let pre_build = custom_build
809 .map(str::to_owned)
810 .or_else(|| framework.as_ref().filter(|f| f.workers).and(framework_build));
811 let mut steps: Vec<String> = pre_build.clone().into_iter().collect();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9812 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.813 steps.push(wrangler_build(None, None));
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9814 }
815 return Ok(Detected {
816 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.817 framework,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9818 install,
819 build: (!steps.is_empty()).then(|| steps.join(" && ")),
820 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.821 problem: None,
822 pre_build,
823 container: None,
824 fallback: None,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9825 });
826 }
827 let build = custom_build
828 .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.829 .or(framework_build)
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9830 .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.831 // An adapter's Worker, unless the project chose a folder to serve.
832 if !as_static && chosen_output.is_none() && framework.as_ref().is_some_and(|f| f.adapter.is_some()) {
833 // Where its files are is known once it has built: the adapter's
834 // own folder, or where the config it writes says.
835 return Ok(Detected {
836 kind: "workers",
837 output_dir: None,
838 framework,
839 install,
840 pre_build: build.clone(),
841 build,
842 problem: None,
843 container: None,
844 fallback: None,
845 });
846 }
847 // A command or a folder of the project's own says it knows better, as
848 // does **Build as** set to Static site.
849 let problem = framework
850 .as_ref()
851 .and_then(|f| f.problem.clone())
852 .filter(|_| !as_static && custom_build.is_none() && chosen_output.is_none());
853 if let Some(problem) = problem {
854 return Ok(Detected::nothing(framework, install, problem));
855 }
856 let output_dir = chosen_output
857 .map(str::to_owned)
858 .or_else(|| framework.as_ref().and_then(|f| f.output.clone()))
859 .or_else(|| find_output(dir));
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9860 // `.` is the project itself: its own files, served as they are.
861 let kind = match (&build, output_dir.as_deref()) {
862 (None, Some(".")) => "html",
863 (None, None) => "none",
864 _ => "static",
865 };
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.866 Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None })
867}
868
869/// The compatibility date a Worker gets when its config names none.
870const COMPATIBILITY_DATE: &str = "2026-09-26";
871
872/// A Workers project bundled by wrangler: `config` with its paths relative
873/// to `base`, read by wrangler from `config_file` when given (else the
874/// project's own, or the one its build wrote).
875fn build_worker(
876 log: &mut Log,
877 secrets: &[String],
878 config: WranglerConfig,
879 base: &Path,
880 config_file: Option<&Path>,
881) -> Result<(Value, Option<PathBuf>, Vec<String>)> {
882 let bundle = bundle_dir();
883 let warnings = config_warnings(&config);
884 let mut worker = json!({
885 "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| COMPATIBILITY_DATE.to_owned()),
886 "compatibilityFlags": config.compatibility_flags,
887 "vars": config.vars,
888 });
889 if let Some(main) = &config.main {
890 // `--dry-run` runs the config's own build and bundles it, without
891 // deploying anywhere.
892 let _ = std::fs::remove_dir_all(&bundle);
893 step(log, &wrangler_build(Some(&bundle), config_file), secrets)?;
894 let (main_module, modules) = bundle_modules(main, &bundle)?;
895 worker["mainModule"] = main_module.into();
896 worker["modules"] = serde_json::to_value(modules)?;
897 }
898 let assets = config.assets.unwrap_or_default();
899 let assets_dir = assets.directory.as_ref().map(|directory| base.join(directory));
900 worker["assetsBinding"] = assets.binding.into();
901 worker["htmlHandling"] = assets.html_handling.into();
902 worker["notFoundHandling"] = assets.not_found_handling.into();
903 Ok((worker, assets_dir, warnings))
904}
905
906/// Where the config for an adapter's Worker is written: beside the
907/// bundle, never in the checkout.
908fn adapter_config_path() -> PathBuf {
909 let bundle = bundle_dir();
910 bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy()))
911}
912
913/// What a container build says when g1t.page runs no containers.
914const CONTAINERS_OFF: &str = "This project deploys as a container, and Docker deployments are not switched on for g1t.page yet. \
915 Set **Build as** to Static site or Workers under Settings, Deployments if it can be built as one.";
916
917/// Where a container's registry login is kept for the build: its own
918/// folder, never the job's `~/.docker`.
919fn docker_config_dir() -> PathBuf {
920 bundle_dir().with_file_name(format!("{}.docker", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
921}
922
923/// Where the Dockerfile g1t writes for a project without one goes: beside
924/// the bundle, never in the checkout.
925fn generated_dockerfile_path() -> PathBuf {
926 bundle_dir().with_file_name(format!("{}.Dockerfile", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
927}
928
929/// What the deployments service answers the `registry` step with: where
930/// to push the app's image, with a login good for pushing only and only
931/// for minutes, and the largest image the app's instance can run.
932#[derive(Debug, Deserialize)]
933struct RegistryGrant {
934 registry: String,
935 repository: String,
936 tag: String,
937 username: String,
938 password: String,
939 #[serde(alias = "maxImageBytes")]
940 max_image_bytes: u64,
941 #[serde(default, alias = "instanceType")]
942 instance_type: Option<String>,
943}
944
945/// `name@sha256:…` for `repository` among an image's repository digests,
946/// as `docker image inspect` lists them, one a line.
947fn pushed_digest(repo_digests: &str, registry: &str, repository: &str) -> Option<String> {
948 let prefix = format!("{registry}/{repository}@sha256:");
949 repo_digests
950 .lines()
951 .map(str::trim)
952 .find(|line| line.starts_with(&prefix) && line.len() == prefix.len() + 64)
953 .map(str::to_owned)
954}
955
956/// Bytes, as a person reads them: `1.4 GB`.
957fn size_text(bytes: u64) -> String {
958 format!("{:.1} GB", bytes as f64 / 1_000_000_000.0)
959}
960
961/// Runs `docker` with `args`, signed in through `config`; its output, or
962/// why it failed, redacted.
963fn docker(args: &[&str], config: &Path, input: Option<&str>, secrets: &[String]) -> Result<String> {
964 use std::io::Write;
965 use std::process::{Command, Stdio};
966 let mut command = Command::new("docker");
967 command.args(args).env("DOCKER_CONFIG", config).stdout(Stdio::piped()).stderr(Stdio::piped());
968 command.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() });
969 let mut child = command.spawn().context("could not run docker")?;
970 if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
971 stdin.write_all(input.as_bytes())?;
972 }
973 let output = child.wait_with_output()?;
974 let text = redact(&format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)), secrets);
975 if !output.status.success() {
976 bail!("`docker {}` failed: {}", args.first().copied().unwrap_or_default(), text.trim());
977 }
978 Ok(text)
979}
980
981/// Builds the project's container image with Docker and pushes it to
982/// Cloudflare's registry, at the place and with the short-lived push-only
983/// login the deployments service gives for this build (`registry`). The
984/// sandbox never holds g1t's own Cloudflare token. None when g1t.page runs
985/// no containers (the service answers `not_enabled`).
986fn container_build(reporter: &Reporter, log: &mut Log, secrets: &[String], dir: &Path, plan: &Detected) -> Result<Option<Value>> {
987 let build = plan.container.as_ref().context("not a container")?;
988 // Asked first: a build that could not be pushed is not run.
989 let answer = reporter.send("registry", json!({}))?;
990 if answer["ok"] == false {
991 if answer["error"]["code"] == "not_enabled" {
992 return Ok(None);
993 }
994 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the image's upload"));
995 }
996 let grant: RegistryGrant =
997 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the image's upload was not understood")?;
998 let mut secrets = secrets.to_vec();
999 secrets.push(grant.password.clone());
1000 let dockerfile = match (&build.dockerfile, &build.generated) {
1001 (Some(own), _) => {
1002 log.line(&format!("Building a container from {own}, listening on port {}.", build.port));
1003 dir.join(own)
1004 }
1005 (None, Some(server)) => {
1006 // Shown whole, so it can be committed and changed.
1007 log.line(&format!("This {} project has no Dockerfile, so g1t wrote this one (commit it as `Dockerfile` to change it):", server.name));
1008 for line in server.dockerfile.lines() {
1009 log.line(&format!(" {line}"));
1010 }
1011 let path = generated_dockerfile_path();
1012 std::fs::write(&path, &server.dockerfile)?;
1013 // BuildKit reads `<Dockerfile>.dockerignore` beside it.
1014 std::fs::write(path.with_file_name(format!("{}.dockerignore", path.file_name().unwrap_or_default().to_string_lossy())), container::DOCKERIGNORE)?;
1015 path
1016 }
1017 (None, None) => bail!("nothing to build the container from"),
1018 };
1019 if std::env::var("BUILD_COMMAND").is_ok_and(|c| !c.trim().is_empty()) {
1020 log.line("Build command is not used for a container: its Dockerfile builds it.");
1021 }
1022 // A sandbox's own Docker Engine, started for the build; in a workflow
1023 // step, the job's.
1024 if clones() {
1025 if std::env::var("G1T_DOCKER").as_deref() != Ok("on") {
1026 bail!("Docker is turned off for g1t's sandboxes here, so a container cannot be built.");
1027 }
1028 crate::docker::engine::enable(crate::docker::engine::Options::default())
1029 .map_err(|problem| anyhow::anyhow!("Docker is not available in this sandbox: {problem}"))?;
1030 let started = crate::docker::engine::ensure_started();
1031 for note in crate::docker::take_notes() {
1032 log.line(&note);
1033 }
1034 started.map_err(|problem| anyhow::anyhow!("Docker could not start: {problem}"))?;
1035 }
1036 let config = docker_config_dir();
1037 std::fs::create_dir_all(&config)?;
1038 let image = format!("{}/{}:{}", grant.registry, grant.repository, grant.tag);
1039 let quote = |path: &Path| format!("'{}'", path.display().to_string().replace('\'', r"'\''"));
1040 let built = step(
1041 log,
1042 &format!("docker build --platform linux/amd64 --pull -f {} -t {image} .", quote(&dockerfile)),
1043 &secrets,
1044 );
1045 let pushed = built.and_then(|()| {
1046 let size: u64 = docker(&["image", "inspect", "--format", "{{.Size}}", &image], &config, None, &secrets)?.trim().parse().unwrap_or(0);
1047 log.line(&format!("The image is {}.", size_text(size)));
1048 if size > grant.max_image_bytes {
1049 bail!(
1050 "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.",
1051 size_text(size),
1052 size_text(grant.max_image_bytes),
1053 grant.instance_type.as_deref().unwrap_or("its instance type"),
1054 );
1055 }
1056 docker(&["login", &grant.registry, "--username", &grant.username, "--password-stdin"], &config, Some(&grant.password), &secrets)?;
1057 log.line(&format!("$ docker push {image}"));
1058 let output = docker(&["push", &image], &config, None, &secrets)?;
1059 log.line(output.trim_end());
1060 let digests = docker(&["image", "inspect", "--format", "{{join .RepoDigests \"\\n\"}}", &image], &config, None, &secrets)?;
1061 let reference = pushed_digest(&digests, &grant.registry, &grant.repository).context("the pushed image's digest was not found")?;
1062 Ok(json!({
1063 "image": reference,
1064 "port": build.port,
1065 "sizeBytes": size,
1066 "dockerfile": build.dockerfile,
1067 "generated": build.generated.as_ref().map(|server| server.id),
1068 }))
1069 });
1070 let _ = std::fs::remove_dir_all(&config);
1071 let _ = std::fs::remove_file(generated_dockerfile_path());
1072 pushed.map(Some)
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91073}
1074
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1075fn build(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Built> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1076 let project = project_dir();
1077 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.1078 let custom_build = std::env::var("BUILD_COMMAND").ok();
1079 let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty());
1080 let build_as = BuildAs::parse(&std::env::var("BUILD_AS").unwrap_or_default());
1081 let dockerfile = std::env::var("DOCKERFILE").ok();
1082 let mut plan = detect_as(dir, custom_build.as_deref(), chosen.as_deref(), build_as, dockerfile.as_deref())?;
1083 if let Some(framework) = &plan.framework {
1084 log.line(&format!("Detected {}: framework={}.", framework.name, framework.id));
1085 }
1086 // Said before anything is installed: it would fail after, less clearly.
1087 if let Some(problem) = &plan.problem {
1088 bail!("{problem}");
1089 }
1090 if plan.container.is_some() {
1091 match container_build(reporter, log, secrets, dir, &plan)? {
1092 Some(image) => {
1093 return Ok(Built {
1094 worker: json!({}),
1095 assets_dir: None,
1096 warnings: Vec::new(),
1097 detected: "container",
1098 framework: plan.framework_id(),
1099 container: Some(image),
1100 });
1101 }
1102 // g1t.page runs no containers yet: the site it is without its
1103 // Dockerfile, as it was built before.
1104 None => match plan.fallback.take() {
1105 Some(site) => {
1106 log.line(&format!("Building it as a {} site instead, as it is without its Dockerfile.", if site.kind == "html" { "plain HTML" } else { "static" }));
1107 plan = *site;
1108 }
1109 None => bail!("{CONTAINERS_OFF}"),
1110 },
1111 }
1112 }
1113 let framework_id = plan.framework.as_ref().map(|f| f.id);
1114 if let Some(install) = &plan.install {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1115 step(log, install, secrets)?;
1116 }
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1117 if let Some(command) = &plan.pre_build {
1118 step(log, command, secrets)?;
1119 }
1120 // A static site (by **Build as**, or found to be one) never reads a
1121 // Workers config.
1122 let workers = plan.kind == "workers";
1123 let own_config = if workers { read_wrangler(dir)? } else { None };
1124 // A framework built for Workers may write the config wrangler reads
1125 // (`@cloudflare/vite-plugin` does, and Astro's adapter with it), with
1126 // or without one of the project's own.
1127 let written = if own_config.is_some() || (workers && plan.framework.as_ref().is_some_and(|f| f.workers)) {
1128 redirected_config(dir)?
1129 } else {
1130 None
1131 };
1132 if let Some((config, base)) = written {
1133 log.line(&format!("Using the Workers config the build wrote, in {}.", base.display()));
1134 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, &base, None)?;
1135 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1136 }
1137 if let Some(config) = own_config {
1138 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, dir, None)?;
1139 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1140 }
1141 let adapter = plan.framework.as_ref().and_then(|f| f.adapter.as_ref()).filter(|_| workers && chosen.is_none());
1142 if let Some(adapter) = adapter
1143 && let Some(entry) = adapter_entry(dir, adapter)
1144 {
1145 // The adapter's Worker, bundled as a Workers project's is. Its
1146 // static files are uploaded by g1t, so wrangler is given none.
1147 let file = adapter_config_path();
1148 let flags = vec!["nodejs_compat".to_owned()];
1149 std::fs::write(
1150 &file,
1151 serde_json::to_string_pretty(&json!({
1152 "name": "g1t-app",
1153 "main": entry,
1154 "compatibility_date": COMPATIBILITY_DATE,
1155 "compatibility_flags": flags,
1156 }))?,
1157 )?;
1158 log.line(&format!("Bundling the Worker the adapter wrote, {}.", adapter.entry));
1159 let config = WranglerConfig {
1160 main: Some(entry.to_string_lossy().into_owned()),
1161 compatibility_flags: flags,
1162 assets: Some(AssetsConfig {
1163 directory: Some(adapter.assets.to_owned()),
1164 binding: Some("ASSETS".to_owned()),
1165 ..AssetsConfig::default()
1166 }),
1167 ..WranglerConfig::default()
1168 };
1169 let built = build_worker(log, secrets, config, dir, Some(&file));
1170 let _ = std::fs::remove_file(&file);
1171 let (worker, assets_dir, warnings) = built?;
1172 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1173 }
1174 if let (Some(adapter), Some(framework)) = (adapter, &plan.framework)
1175 && framework.output.is_none()
1176 {
1177 bail!("{}'s build did not write its Worker, `{}`", framework.name, adapter.entry);
1178 }
1179 let assets_dir = match &chosen {
1180 Some(chosen) => {
1181 let path = dir.join(chosen.trim().trim_matches('/'));
1182 if !path.is_dir() {
1183 bail!("the output directory `{chosen}` does not exist after the build");
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1184 }
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1185 path
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1186 }
1187 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.1188 let expected = plan.framework.as_ref().and_then(|f| f.output.as_deref());
1189 match expected.filter(|output| dir.join(output).is_dir()) {
1190 Some(output) => dir.join(output),
1191 None => {
1192 let found = find_output(dir).with_context(|| match (&plan.framework, expected) {
1193 (Some(framework), Some(expected)) => format!(
1194 "found nothing to serve: {} writes its site to `{expected}`, which the build did not write. \
1195 Set **Output directory** if it writes somewhere else.",
1196 framework.name
1197 ),
1198 (Some(framework), None) if framework.problem.is_some() => {
1199 format!("found nothing to serve. {}", framework.problem.as_deref().unwrap_or_default())
1200 }
1201 _ => "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public".to_owned(),
1202 })?;
1203 if let Some(expected) = expected {
1204 log.line(&format!("`{expected}` was not written; serving `{found}`."));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1205 }
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1206 if found == "." { dir.to_path_buf() } else { dir.join(found) }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1207 }
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1208 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1209 }
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1210 };
1211 let spa = !assets_dir.join("404.html").exists();
1212 Ok(Built {
1213 worker: json!({
1214 "compatibilityDate": COMPATIBILITY_DATE,
1215 "compatibilityFlags": [],
1216 "vars": {},
1217 "notFoundHandling": if spa { "single-page-application" } else { "404-page" },
1218 }),
1219 detected: static_kind(dir, &assets_dir, plan.build.is_some()),
1220 assets_dir: Some(assets_dir),
1221 warnings: Vec::new(),
1222 framework: framework_id,
1223 container: None,
1224 })
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1225}
1226
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91227/// Whether the build checks out its own commit (a sandbox, given
1228/// `GIT_REMOTE`) rather than building the checkout it runs in.
1229fn clones() -> bool {
1230 std::env::var("GIT_REMOTE").is_ok_and(|remote| !remote.trim().is_empty())
1231}
1232
1233/// The checkout the build runs in: the sandbox's clone, or, from a
1234/// workflow step, the current directory.
1235fn checkout_dir() -> PathBuf {
1236 if clones() {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1237 PathBuf::from(WORKDIR)
1238 } else {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91239 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1240 }
1241}
1242
1243/// Where `wrangler deploy --dry-run` writes the bundle: in a workflow
1244/// step, a directory of the job's temporary space, never the checkout.
1245fn bundle_dir() -> PathBuf {
1246 if clones() {
1247 return PathBuf::from(BUNDLE_DIR);
1248 }
1249 let base = std::env::var_os("RUNNER_TEMP").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
1250 base.join(format!("g1t-bundle-{}", std::process::id()))
1251}
1252
1253/// Where the project lives in the checkout `base`: its root directory,
1254/// never above the checkout.
1255fn project_dir_in(base: &Path, root: &str) -> PathBuf {
1256 let root = root.trim().trim_matches('/');
1257 if root.is_empty() || root == "." || root.split('/').any(|part| part == "..") {
1258 base.to_path_buf()
1259 } else {
1260 base.join(root)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1261 }
1262}
1263
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91264/// Where the project lives: `ROOT_DIR` in the checkout.
1265fn project_dir() -> PathBuf {
1266 project_dir_in(&checkout_dir(), &std::env::var("ROOT_DIR").unwrap_or_default())
1267}
1268
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1269fn check_out(secrets: &[String]) -> Result<()> {
1270 let remote = env("GIT_REMOTE")?;
1271 let commit = env("GIT_COMMIT")?;
1272 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
1273 std::fs::create_dir_all("/work")?;
Previews build from any branch; Agent in place of Ask AI; pin from the sidebar1274 let workdir = Path::new(WORKDIR);
1275 // The clone is shallow and of the default branch; a preview's commit is
1276 // often on another one, so it is fetched before checking out.
1277 let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR)
1278 .and_then(|_| git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"]))
1279 .and_then(|branch| crate::clone::ensure(workdir, &auth, "origin", &branch, &commit))
1280 .and_then(|_| git(workdir, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit]));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1281 if let Err(error) = cloned {
1282 bail!("{}", redact(&format!("{error:#}"), secrets));
1283 }
1284 Ok(())
1285}
1286
1287fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91288 if clones() {
1289 check_out(secrets).context("the commit could not be checked out")?;
1290 } else {
1291 let dir = project_dir();
1292 if !dir.is_dir() {
1293 bail!("the project's directory {} does not exist in this checkout", dir.display());
1294 }
1295 log.line(&format!("Building {} as it is checked out.", dir.display()));
1296 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1297 // The repository's variables and secrets for deploy builds.
1298 for source in ["BUILD_ENV", "BUILD_SECRETS"] {
1299 if let Ok(vars) = std::env::var(source)
1300 && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
1301 {
1302 for (name, value) in vars {
1303 if let Some(value) = value.as_str() {
1304 // SAFETY: single-threaded; set before any command runs.
1305 unsafe { std::env::set_var(name, value) };
1306 }
1307 }
1308 }
1309 }
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1310 let built = build(reporter, log, secrets)?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1311 let mut finish = json!({
1312 "worker": built.worker,
1313 "warnings": built.warnings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1314 "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.1315 // What it is built with, shown beside `detected` on its settings.
1316 "framework": built.framework,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1317 });
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1318 if let Some(image) = built.container {
1319 finish["container"] = image;
1320 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91321 for warning in &built.warnings {
1322 log.line(&format!("Warning: {warning}"));
1323 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1324 if let Some(dir) = &built.assets_dir {
1325 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.1326 let assets = collect_site(dir, skip_project)?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1327 if assets.is_empty() {
1328 bail!("the site to serve is empty");
1329 }
1330 log.line(&format!("Uploading {} files.", assets.len()));
1331 for special in ["_headers", "_redirects"] {
1332 if let Ok(text) = std::fs::read_to_string(dir.join(special)) {
1333 finish["worker"][special] = text.into();
1334 }
1335 }
1336 let manifest: BTreeMap<&str, Value> = assets
1337 .iter()
1338 .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size })))
1339 .collect();
1340 let answer = reporter.send("session", json!({ "manifest": manifest }))?;
1341 if answer["ok"] == false {
1342 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload"));
1343 }
1344 let session: UploadSession =
1345 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?;
1346 let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect();
1347 let mut completion = session.jwt.clone();
1348 for bucket in &session.buckets {
1349 if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? {
1350 completion = jwt;
1351 }
1352 }
1353 finish["completionJwt"] = completion.into();
1354 }
1355 Ok(finish)
1356}
1357
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91358/// A sandbox's build (`MODE=deploy`).
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1359pub fn main() -> i32 {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91360 run(false)
1361}
1362
1363/// `g1t-runner deploy`, from a workflow step: the build in the job's own
1364/// checkout, its log in the step's as well. Exits 1 when the build fails,
1365/// after reporting it, so the step fails too.
1366pub fn cli(args: &[String]) -> i32 {
1367 if args.iter().any(|arg| arg == "--help" || arg == "-h") {
1368 println!("{CLI_HELP}");
1369 return 0;
1370 }
1371 run(true)
1372}
1373
1374const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page.
1375
1376Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds),
1377G1T_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.1378BUILD_AS (auto, static, workers or docker), DOCKERFILE, and BUILD_ENV and
1379BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. A container
1380is built with the docker on PATH.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91381
1382g1t-runner detect prints what the build would find, as key=value lines.";
1383
1384fn run(step_log: bool) -> i32 {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1385 let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
1386 (Ok(api), Ok(id), Ok(token)) => Reporter {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91387 base: format!("{}/deployments/jobs/{id}", api.trim_end_matches('/')),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1388 token,
1389 },
1390 _ => {
1391 eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set");
1392 return 2;
1393 }
1394 };
1395 let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
1396 .iter()
1397 .filter_map(|name| std::env::var(name).ok())
1398 .filter(|secret| !secret.is_empty())
1399 .collect();
1400 // The repository's build secrets never appear in the log.
1401 if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) {
1402 secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned));
1403 }
1404 if let Err(error) = reporter.send("started", json!({})) {
1405 eprintln!("g1t-runner: {error:#}");
1406 return 1;
1407 }
1408 let started = Instant::now();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91409 let mut log = Log { echo: step_log.then(|| secrets.clone()), ..Log::default() };
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1410 let outcome = deploy(&reporter, &mut log, &secrets);
1411 let seconds = started.elapsed().as_secs();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91412 if step_log {
1413 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.1414 let _ = std::fs::remove_file(adapter_config_path());
1415 let _ = std::fs::remove_dir_all(docker_config_dir());
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91416 }
1417 let (sent, failed) = match outcome {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1418 Ok(mut finish) => {
1419 finish["log"] = redact(&log.tail(), &secrets).into();
1420 finish["buildSeconds"] = seconds.into();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91421 (reporter.send("finish", finish), false)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1422 }
1423 Err(error) => {
1424 let message = redact(&format!("{error:#}"), &secrets);
1425 log.line(&format!("The build failed: {message}"));
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91426 let sent = reporter.send(
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1427 "fail",
1428 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91429 );
1430 (sent, true)
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1431 }
1432 };
1433 match sent {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91434 // A sandbox's build has said how it ended; a step fails with its build.
1435 Ok(answer) if step_log && (failed || answer["ok"] == false || answer["value"] == false) => {
1436 if !failed {
1437 eprintln!("g1t-runner: g1t did not take the build; its deployment says why");
1438 }
1439 1
1440 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1441 Ok(_) => 0,
1442 Err(error) => {
1443 eprintln!("g1t-runner: {error:#}");
1444 1
1445 }
1446 }
1447}
1448
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91449/// `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.1450/// `key=value` lines for `$GITHUB_OUTPUT`: `kind`, `install`, `build`,
1451/// `output-dir`, and `framework` when one is found. Reads `ROOT_DIR`,
1452/// `BUILD_COMMAND` and `OUTPUT_DIR` as the build does; `--root-dir DIR` is
1453/// the same as `ROOT_DIR`. A framework set up to need a server is `none`,
1454/// with what to change on standard error.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91455pub fn detect_cli(args: &[String]) -> i32 {
1456 let mut root = std::env::var("ROOT_DIR").unwrap_or_default();
1457 let mut rest = args.iter();
1458 while let Some(arg) = rest.next() {
1459 match arg.as_str() {
1460 "--root-dir" => root = rest.next().cloned().unwrap_or_default(),
1461 "--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.1462 println!(
1463 "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."
1464 );
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91465 return 0;
1466 }
1467 other => {
1468 eprintln!("g1t-runner detect: unknown argument {other}");
1469 return 2;
1470 }
1471 }
1472 }
1473 let dir = project_dir_in(&checkout_dir(), &root);
1474 let custom = std::env::var("BUILD_COMMAND").ok();
1475 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.1476 let build_as = BuildAs::parse(&std::env::var("BUILD_AS").unwrap_or_default());
1477 let dockerfile = std::env::var("DOCKERFILE").ok();
1478 match detect_as(&dir, custom.as_deref(), output.as_deref(), build_as, dockerfile.as_deref()) {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91479 Ok(found) => {
1480 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.1481 if let Some(problem) = &found.problem {
1482 eprintln!("g1t-runner detect: {problem}");
1483 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91484 0
1485 }
1486 Err(error) => {
1487 eprintln!("g1t-runner detect: {error:#}");
1488 1
1489 }
1490 }
1491}
1492
1493/// `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.1494/// there is none. `framework` is left out when none was found, which a
1495/// step's outputs read as empty all the same.
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91496fn output_lines(found: &Detected) -> String {
1497 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.1498 let mut lines = format!(
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91499 "kind={}\ninstall={}\nbuild={}\noutput-dir={}\n",
1500 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.1501 one_line(found.install.as_deref()),
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91502 one_line(found.build.as_deref()),
1503 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.1504 );
1505 if let Some(framework) = found.framework_id() {
1506 lines.push_str(&format!("framework={framework}\n"));
1507 }
1508 // A container's Dockerfile (empty when g1t writes one) and its port.
1509 if let Some(container) = &found.container {
1510 lines.push_str(&format!("dockerfile={}\nport={}\n", one_line(container.dockerfile.as_deref()), container.port));
1511 }
1512 lines
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91513}
1514
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1515#[cfg(test)]
1516mod tests {
1517 use super::*;
1518
1519 #[test]
1520 fn the_upload_session_is_read_in_either_spelling() {
1521 for answer in [
1522 json!({ "jwt": "j", "buckets": [["a"]], "upload_url": "https://u" }),
1523 json!({ "jwt": "j", "uploadUrl": "https://u" }),
1524 ] {
1525 let session: UploadSession = serde_json::from_value(answer).unwrap();
1526 assert_eq!(session.upload_url, "https://u");
1527 }
1528 }
1529
1530 #[test]
1531 fn jsonc_comments_and_trailing_commas_are_dropped() {
1532 let text = r#"{
1533 // a comment
1534 "main": "src/index.ts", /* another */
1535 "vars": { "URL": "https://x.dev//not-a-comment", },
1536 }"#;
1537 let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap();
1538 assert_eq!(config.main.as_deref(), Some("src/index.ts"));
1539 assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment");
1540 }
1541
1542 #[test]
1543 fn unsupported_bindings_are_noticed() {
1544 let config: WranglerConfig =
1545 serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap();
1546 assert!(config.rest.contains_key("d1_databases"));
1547 }
1548
1549 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1550 fn crons_g1t_does_not_run_are_warned_about() {
1551 let config: WranglerConfig =
1552 serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": ["*/5 * * * *", "0 3 * * *"] }, "kv_namespaces": [{ "binding": "KV" }] }"#)
1553 .unwrap();
1554 let warnings = config_warnings(&config);
1555 assert_eq!(warnings.len(), 2);
1556 assert!(warnings.iter().any(|w| w.contains("kv_namespaces")));
1557 assert!(warnings.iter().any(|w| w.contains("triggers.crons") && w.contains("2 schedules")));
1558 let none: WranglerConfig = serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": [] } }"#).unwrap();
1559 assert!(config_warnings(&none).is_empty());
1560 let toml: WranglerConfig = toml::from_str("main = \"a.js\"
1561[triggers]
1562crons = [\"0 * * * *\"]
1563").unwrap();
1564 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.1565 // A config a build wrote lists every kind of binding, mostly empty.
1566 let written: WranglerConfig = serde_json::from_str(
1567 r#"{ "main": "entry.mjs", "d1_databases": [], "durable_objects": { "bindings": [] }, "triggers": {}, "kv_namespaces": [{ "binding": "SESSION" }] }"#,
1568 )
1569 .unwrap();
1570 assert_eq!(config_warnings(&written).len(), 1);
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1571 }
1572
1573 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1574 fn a_site_is_plain_html_only_when_its_own_files_are_served_unbuilt() {
1575 let project = Path::new("/w/site");
1576 assert_eq!(static_kind(project, project, false), "html");
1577 assert_eq!(static_kind(project, project, true), "static");
1578 assert_eq!(static_kind(project, &project.join("dist"), false), "static");
1579 }
1580
1581 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1582 fn files_are_typed_by_extension() {
1583 assert_eq!(content_type("/index.HTML"), "text/html");
1584 assert_eq!(content_type("/a/b.woff2"), "font/woff2");
1585 assert_eq!(content_type("/LICENSE"), "application/octet-stream");
1586 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91587
1588 /// A fresh directory with `files` (path, contents) in it.
1589 fn project(name: &str, files: &[(&str, &str)]) -> PathBuf {
1590 let dir = std::env::temp_dir().join(format!("g1t-detect-{name}-{}", std::process::id()));
1591 let _ = std::fs::remove_dir_all(&dir);
1592 for (path, text) in files {
1593 let path = dir.join(path);
1594 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1595 std::fs::write(path, text).unwrap();
1596 }
1597 std::fs::create_dir_all(&dir).unwrap();
1598 dir
1599 }
1600
1601 #[test]
1602 fn detect_finds_a_workers_project_and_how_it_builds() {
1603 let dir = project(
1604 "workers",
1605 &[
1606 ("wrangler.jsonc", r#"{ "main": "src/index.ts", "assets": { "directory": "./public" }, }"#),
1607 ("package.json", "{}"),
1608 ("pnpm-lock.yaml", ""),
1609 ],
1610 );
1611 let found = detect(&dir, None, None).unwrap();
1612 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.1613 assert_eq!(found.install.as_deref(), Some("corepack enable && pnpm install --frozen-lockfile"));
1614 assert_eq!(found.framework, None);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91615 assert_eq!(found.build.as_deref(), Some("npx --yes wrangler@4 deploy --dry-run"));
1616 assert_eq!(found.output_dir.as_deref(), Some("./public"));
1617 // The project's own build command runs first.
1618 let custom = detect(&dir, Some("npm run codegen"), None).unwrap();
1619 assert_eq!(custom.build.as_deref(), Some("npm run codegen && npx --yes wrangler@4 deploy --dry-run"));
1620 }
1621
1622 #[test]
1623 fn detect_finds_a_static_build_and_a_plain_site() {
1624 let built = project("static", &[("package.json", r#"{ "scripts": { "build": "vite build" } }"#), ("package-lock.json", "{}")]);
1625 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.1626 assert_eq!(found.kind, "static");
1627 assert_eq!(found.install.as_deref(), Some("npm ci"));
1628 assert_eq!(found.build.as_deref(), Some("npm run build"));
1629 assert_eq!(found.output_dir, None, "its output is found once it has built");
1630 assert_eq!(found.framework, None, "a build script alone names no framework");
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91631 std::fs::create_dir_all(built.join("dist")).unwrap();
1632 std::fs::write(built.join("dist/index.html"), "<h1>hi</h1>").unwrap();
1633 assert_eq!(detect(&built, None, None).unwrap().output_dir.as_deref(), Some("dist"));
1634 assert_eq!(detect(&built, None, Some("/site/")).unwrap().output_dir.as_deref(), Some("site"));
1635
1636 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.1637 let found = detect(&html, None, None).unwrap();
1638 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-7nm8r91639 // A `public` folder alone, without an index.html, is not a site.
1640 let empty = project("empty", &[("public/robots.txt", ""), ("README.md", "")]);
1641 assert_eq!(detect(&empty, None, None).unwrap().kind, "none");
1642 }
1643
1644 #[test]
1645 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.1646 let mut found = Detected {
1647 kind: "static",
1648 framework: None,
1649 install: Some("npm ci".into()),
1650 build: Some("a\nb".into()),
1651 output_dir: None,
1652 problem: None,
1653 pre_build: None,
1654 container: None,
1655 fallback: None,
1656 };
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91657 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.1658 let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]);
1659 found.framework = frameworks::detect(&vite, false);
1660 assert!(output_lines(&found).ends_with("output-dir=\nframework=vite\n"));
1661 }
1662
1663 /// One project of the table below: its files, the project's own
1664 /// settings, and what detect should say.
1665 struct Case {
1666 name: &'static str,
1667 files: &'static [(&'static str, &'static str)],
1668 custom_build: Option<&'static str>,
1669 chosen_output: Option<&'static str>,
1670 kind: &'static str,
1671 framework: Option<&'static str>,
1672 build: Option<&'static str>,
1673 output_dir: Option<&'static str>,
1674 /// A phrase the problem says, if there is one.
1675 problem: Option<&'static str>,
1676 }
1677
1678 const CASE: Case = Case {
1679 name: "",
1680 files: &[],
1681 custom_build: None,
1682 chosen_output: None,
1683 kind: "static",
1684 framework: None,
1685 build: Some("npm run build"),
1686 output_dir: None,
1687 problem: None,
1688 };
1689
1690 #[test]
1691 fn detect_knows_each_framework() {
1692 let cases = [
1693 Case {
1694 name: "vite",
1695 files: &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)],
1696 framework: Some("vite"),
1697 build: Some("npx vite build"),
1698 output_dir: Some("dist"),
1699 ..CASE
1700 },
1701 Case {
1702 name: "vite-react",
1703 files: &[
1704 ("package.json", r#"{ "scripts": { "build": "tsc && vite build" }, "dependencies": { "react": "19" }, "devDependencies": { "vite": "7", "@vitejs/plugin-react": "5" } }"#),
1705 ("vite.config.ts", "export default defineConfig({ plugins: [react()], build: { outDir: 'site' } })"),
1706 ],
1707 framework: Some("vite-react"),
1708 output_dir: Some("site"),
1709 ..CASE
1710 },
1711 Case {
1712 name: "vite-vue",
1713 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7", "@vitejs/plugin-vue": "6" } }"#)],
1714 framework: Some("vite-vue"),
1715 output_dir: Some("dist"),
1716 ..CASE
1717 },
1718 Case {
1719 name: "vite-svelte",
1720 files: &[("package.json", r#"{ "devDependencies": { "vite": "7", "@sveltejs/vite-plugin-svelte": "6" } }"#)],
1721 framework: Some("vite-svelte"),
1722 build: Some("npx vite build"),
1723 output_dir: Some("dist"),
1724 ..CASE
1725 },
1726 Case {
1727 name: "vite-solid",
1728 files: &[("package.json", r#"{ "devDependencies": { "vite": "7", "vite-plugin-solid": "2" } }"#)],
1729 framework: Some("vite-solid"),
1730 build: Some("npx vite build"),
1731 output_dir: Some("dist"),
1732 ..CASE
1733 },
1734 Case {
1735 name: "vite-cloudflare",
1736 files: &[
1737 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7", "@cloudflare/vite-plugin": "1" } }"#),
1738 ("wrangler.jsonc", r#"{ "main": "worker/index.ts" }"#),
1739 ],
1740 kind: "workers",
1741 framework: Some("vite"),
1742 build: Some("npm run build && npx --yes wrangler@4 deploy --dry-run"),
1743 ..CASE
1744 },
1745 Case {
1746 name: "astro",
1747 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5" } }"#), ("astro.config.mjs", "export default defineConfig({ site: 'https://x.dev' })")],
1748 framework: Some("astro"),
1749 output_dir: Some("dist"),
1750 ..CASE
1751 },
1752 Case {
1753 name: "astro-server",
1754 files: &[("package.json", r#"{ "dependencies": { "astro": "5", "@astrojs/node": "9" } }"#), ("astro.config.mjs", "export default { output: \"server\" }")],
1755 kind: "container",
1756 framework: Some("astro"),
1757 build: Some(GENERATED),
1758 ..CASE
1759 },
1760 Case {
1761 name: "astro-cloudflare",
1762 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5", "@astrojs/cloudflare": "12" } }"#)],
1763 kind: "workers",
1764 framework: Some("astro"),
1765 ..CASE
1766 },
1767 Case {
1768 name: "astro-cloudflare-folder",
1769 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5", "@astrojs/cloudflare": "12" } }"#)],
1770 chosen_output: Some("dist"),
1771 framework: Some("astro"),
1772 output_dir: Some("dist"),
1773 ..CASE
1774 },
1775 Case {
1776 name: "next-export",
1777 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.ts", "const config: NextConfig = {\n output: 'export',\n};")],
1778 framework: Some("nextjs"),
1779 output_dir: Some("out"),
1780 ..CASE
1781 },
1782 Case {
1783 name: "next-server",
1784 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.mjs", "export default {}")],
1785 kind: "container",
1786 framework: Some("nextjs"),
1787 build: Some(GENERATED),
1788 ..CASE
1789 },
1790 Case {
1791 name: "next-server-own-folder",
1792 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#)],
1793 chosen_output: Some("out"),
1794 framework: Some("nextjs"),
1795 output_dir: Some("out"),
1796 ..CASE
1797 },
1798 Case {
1799 name: "opennext",
1800 files: &[
1801 ("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#),
1802 ("wrangler.jsonc", r#"{ "main": ".open-next/worker.js", "assets": { "directory": ".open-next/assets", "binding": "ASSETS" } }"#),
1803 ],
1804 kind: "workers",
1805 framework: Some("nextjs"),
1806 build: Some("npx opennextjs-cloudflare build && npx --yes wrangler@4 deploy --dry-run"),
1807 output_dir: Some(".open-next/assets"),
1808 ..CASE
1809 },
1810 Case {
1811 name: "opennext-no-config",
1812 files: &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)],
1813 kind: "none",
1814 framework: Some("nextjs"),
1815 build: None,
1816 problem: Some("`.open-next/worker.js`"),
1817 ..CASE
1818 },
1819 Case {
1820 name: "sveltekit-static",
1821 files: &[
1822 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-static": "3", "vite": "7" } }"#),
1823 ("svelte.config.js", "import adapter from '@sveltejs/adapter-static';\nexport default { kit: { adapter: adapter() } };"),
1824 ],
1825 framework: Some("sveltekit"),
1826 output_dir: Some("build"),
1827 ..CASE
1828 },
1829 Case {
1830 name: "sveltekit-3-static",
1831 files: &[
1832 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "3", "@sveltejs/adapter-static": "4", "@sveltejs/adapter-cloudflare": "8" } }"#),
1833 ("vite.config.js", "import adapter from '@sveltejs/adapter-static';\nexport default defineConfig({ plugins: [sveltekit({ adapter: adapter({ pages: 'www' }) })] });"),
1834 ],
1835 framework: Some("sveltekit"),
1836 output_dir: Some("www"),
1837 ..CASE
1838 },
1839 Case {
1840 name: "sveltekit-cloudflare",
1841 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-cloudflare": "7" } }"#)],
1842 kind: "workers",
1843 framework: Some("sveltekit"),
1844 ..CASE
1845 },
1846 Case {
1847 name: "sveltekit-auto",
1848 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-auto": "6" } }"#)],
1849 kind: "none",
1850 framework: Some("sveltekit"),
1851 build: None,
1852 problem: Some("Or add a `Dockerfile`"),
1853 ..CASE
1854 },
1855 Case {
1856 name: "nuxt",
1857 files: &[("package.json", r#"{ "scripts": { "build": "nuxt build", "generate": "nuxt generate" }, "dependencies": { "nuxt": "4" } }"#)],
1858 framework: Some("nuxt"),
1859 build: Some("npm run generate"),
1860 output_dir: Some(".output/public"),
1861 ..CASE
1862 },
1863 Case {
1864 name: "nuxt-no-generate",
1865 files: &[("package.json", r#"{ "scripts": { "build": "nuxt build" }, "dependencies": { "nuxt": "4" } }"#)],
1866 framework: Some("nuxt"),
1867 build: Some("npx nuxi generate"),
1868 output_dir: Some(".output/public"),
1869 ..CASE
1870 },
1871 Case {
1872 name: "react-router-spa",
1873 files: &[
1874 ("package.json", r#"{ "scripts": { "build": "react-router build" }, "devDependencies": { "@react-router/dev": "7", "vite": "7" } }"#),
1875 ("react-router.config.ts", "export default {\n // Server-side render by default\n ssr: false,\n} satisfies Config;"),
1876 ],
1877 framework: Some("react-router"),
1878 output_dir: Some("build/client"),
1879 ..CASE
1880 },
1881 Case {
1882 name: "react-router-ssr",
1883 files: &[("package.json", r#"{ "devDependencies": { "@react-router/dev": "7" } }"#), ("react-router.config.ts", "export default { ssr: true }")],
1884 kind: "container",
1885 framework: Some("react-router"),
1886 build: Some(GENERATED),
1887 ..CASE
1888 },
1889 Case {
1890 name: "remix-spa",
1891 files: &[
1892 ("package.json", r#"{ "scripts": { "build": "remix vite:build" }, "devDependencies": { "@remix-run/dev": "2", "vite": "5" } }"#),
1893 ("vite.config.ts", "export default defineConfig({ plugins: [remix({ ssr: false })] })"),
1894 ],
1895 framework: Some("remix"),
1896 output_dir: Some("build/client"),
1897 ..CASE
1898 },
1899 Case {
1900 name: "gatsby",
1901 files: &[("package.json", r#"{ "scripts": { "build": "gatsby build" }, "dependencies": { "gatsby": "5" } }"#)],
1902 framework: Some("gatsby"),
1903 output_dir: Some("public"),
1904 ..CASE
1905 },
1906 Case {
1907 name: "docusaurus",
1908 files: &[("package.json", r#"{ "scripts": { "build": "docusaurus build" }, "dependencies": { "@docusaurus/core": "3" } }"#)],
1909 framework: Some("docusaurus"),
1910 output_dir: Some("build"),
1911 ..CASE
1912 },
1913 Case {
1914 name: "eleventy",
1915 files: &[("package.json", r#"{ "devDependencies": { "@11ty/eleventy": "3" } }"#)],
1916 framework: Some("eleventy"),
1917 build: Some("npx @11ty/eleventy"),
1918 output_dir: Some("_site"),
1919 ..CASE
1920 },
1921 Case {
1922 name: "angular",
1923 files: &[
1924 ("package.json", r#"{ "scripts": { "build": "ng build" }, "dependencies": { "@angular/core": "20" } }"#),
1925 ("angular.json", r#"{ "projects": { "shop": { "projectType": "application", "architect": { "build": { "builder": "@angular/build:application", "options": { "outputPath": "dist/shop" } } } } } }"#),
1926 ],
1927 framework: Some("angular"),
1928 output_dir: Some("dist/shop/browser"),
1929 ..CASE
1930 },
1931 Case {
1932 name: "angular-default",
1933 files: &[
1934 ("package.json", r#"{ "dependencies": { "@angular/core": "20" } }"#),
1935 ("angular.json", r#"{ "projects": { "app": { "projectType": "application", "architect": { "build": { "builder": "@angular/build:application", "options": {} } } } } }"#),
1936 ],
1937 framework: Some("angular"),
1938 build: Some("npx ng build"),
1939 output_dir: Some("dist/app/browser"),
1940 ..CASE
1941 },
1942 Case {
1943 name: "hugo",
1944 files: &[("hugo.toml", "baseURL = 'https://example.org/'\ntitle = 'Site'\n")],
1945 framework: Some("hugo"),
1946 build: Some(frameworks_hugo()),
1947 output_dir: Some("public"),
1948 ..CASE
1949 },
1950 Case {
1951 name: "hugo-config-toml",
1952 files: &[("config.toml", "baseURL = \"https://example.org/\"\npublishDir = \"site\"\n")],
1953 framework: Some("hugo"),
1954 build: Some(frameworks_hugo()),
1955 output_dir: Some("site"),
1956 ..CASE
1957 },
1958 Case {
1959 name: "not-hugo",
1960 files: &[("config.toml", "base_url = \"https://example.org\"\n"), ("index.html", "")],
1961 kind: "html",
1962 build: None,
1963 output_dir: Some("."),
1964 ..CASE
1965 },
1966 Case {
1967 name: "jekyll",
1968 files: &[("_config.yml", "title: Blog\n"), ("Gemfile", "gem 'jekyll'\n")],
1969 framework: Some("jekyll"),
1970 build: Some("bundle exec jekyll build"),
1971 output_dir: Some("_site"),
1972 ..CASE
1973 },
1974 Case {
1975 name: "mdbook",
1976 files: &[("book.toml", "[book]\ntitle = \"Guide\"\n")],
1977 framework: Some("mdbook"),
1978 build: Some(frameworks_mdbook()),
1979 output_dir: Some("book"),
1980 ..CASE
1981 },
1982 Case {
1983 name: "mdbook-build-dir",
1984 files: &[("book.toml", "[book]\ntitle = \"Guide\"\n[build]\nbuild-dir = \"site\"\n")],
1985 framework: Some("mdbook"),
1986 build: Some(frameworks_mdbook()),
1987 output_dir: Some("site"),
1988 ..CASE
1989 },
1990 // The project's own settings win.
1991 Case {
1992 name: "own-settings",
1993 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7" } }"#)],
1994 custom_build: Some("make site"),
1995 chosen_output: Some("www"),
1996 framework: Some("vite"),
1997 build: Some("make site"),
1998 output_dir: Some("www"),
1999 ..CASE
2000 },
2001 ];
2002 for case in cases {
2003 let dir = project(&format!("fw-{}", case.name), case.files);
2004 let found = detect(&dir, case.custom_build, case.chosen_output).unwrap();
2005 let name = case.name;
2006 assert_eq!(found.kind, case.kind, "{name}: kind");
2007 assert_eq!(found.framework.as_ref().map(|f| f.id), case.framework, "{name}: framework");
2008 assert_eq!(found.build.as_deref(), case.build, "{name}: build");
2009 assert_eq!(found.output_dir.as_deref(), case.output_dir, "{name}: output-dir");
2010 match case.problem {
2011 Some(phrase) => assert!(found.problem.as_deref().is_some_and(|p| p.contains(phrase)), "{name}: {:?}", found.problem),
2012 None => assert_eq!(found.problem, None, "{name}: problem"),
2013 }
2014 let _ = std::fs::remove_dir_all(&dir);
2015 }
2016 }
2017
2018 #[test]
2019 fn build_as_and_dockerfiles_decide_in_order() {
2020 let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap();
2021 // A framework that builds a static site stays one beside a
2022 // Dockerfile: it costs nothing while no one visits.
2023 let vite = project("p-vite", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", "FROM nginx\nEXPOSE 80\n")]);
2024 assert_eq!(detect_with(&vite, BuildAs::Auto, None).kind, "static");
2025 // Unless the project says Docker.
2026 let found = detect_with(&vite, BuildAs::Docker, None);
2027 assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
2028 let container = found.container.unwrap();
2029 assert_eq!((container.dockerfile.as_deref(), container.port), (Some("Dockerfile"), 80));
2030 // A Wrangler config wins in Auto, and not when it says Static site.
2031 let workers = project("p-workers", &[("wrangler.jsonc", r#"{ "assets": { "directory": "public" } }"#), ("index.html", "")]);
2032 assert_eq!(detect_with(&workers, BuildAs::Auto, None).kind, "workers");
2033 assert_eq!(detect_with(&workers, BuildAs::Static, None).kind, "html");
2034 // Workers, without a config or adapter, says what is missing.
2035 let html = project("p-html", &[("index.html", "<h1>hi</h1>")]);
2036 let found = detect_with(&html, BuildAs::Workers, None);
2037 assert_eq!(found.kind, "none");
2038 assert!(found.problem.unwrap().contains("no Wrangler config"));
2039 // No framework and a Dockerfile: a container, built as the site it
2040 // is without one while g1t.page runs no containers.
2041 let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM nginx")]);
2042 let found = detect_with(&both, BuildAs::Auto, None);
2043 assert_eq!((found.kind, found.framework_id()), ("container", Some("dockerfile")));
2044 assert_eq!(found.container.as_ref().unwrap().port, 8080);
2045 assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("html"));
2046 // A Dockerfile at a path of the project's choosing.
2047 let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]);
2048 assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none");
2049 let found = detect_with(&chosen, BuildAs::Auto, Some("ops/web.Dockerfile"));
2050 assert_eq!((found.kind, found.container.unwrap().port), ("container", 3000));
2051 assert!(detect_as(&chosen, None, None, BuildAs::Auto, Some("nope")).is_err());
2052 // A server g1t recognises gets a Dockerfile written for it.
2053 let express = project("p-express", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]);
2054 let found = detect_with(&express, BuildAs::Auto, None);
2055 assert_eq!((found.kind, found.framework_id(), found.install.as_deref()), ("container", Some("node"), None));
2056 assert!(found.fallback.is_none());
2057 assert!(output_lines(&found).ends_with("framework=node\ndockerfile=\nport=8080\n"));
2058 // Nothing at all says to add a Dockerfile.
2059 let nothing = project("p-nothing", &[("README.md", "")]);
2060 let found = detect_with(&nothing, BuildAs::Auto, None);
2061 assert_eq!(found.kind, "none");
2062 assert!(found.problem.as_deref().unwrap().starts_with("found nothing to serve"));
2063 assert!(found.problem.unwrap().contains("Add a `Dockerfile`"));
2064 let found = detect_with(&nothing, BuildAs::Docker, None);
2065 assert!(found.problem.unwrap().contains("**Build as** is Docker"));
2066 // A framework set up for a server, with a Dockerfile of its own.
2067 let next = project("p-next", &[("package.json", r#"{ "dependencies": { "next": "16" } }"#), ("Dockerfile", "FROM node")]);
2068 let found = detect_with(&next, BuildAs::Auto, None);
2069 assert_eq!((found.kind, found.framework_id()), ("container", Some("nextjs")));
2070 assert_eq!(found.container.unwrap().dockerfile.as_deref(), Some("Dockerfile"));
2071 // OpenNext's adapter means Workers: it is not run as a server.
2072 let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]);
2073 assert_eq!(detect_with(&opennext, BuildAs::Auto, None).kind, "none");
2074 for dir in [vite, workers, html, both, chosen, express, nothing, next, opennext] {
2075 let _ = std::fs::remove_dir_all(dir);
2076 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r92077 }
2078
2079 #[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.2080 fn the_pushed_digest_is_the_one_in_the_apps_repository() {
2081 let digest = "a".repeat(64);
2082 let lines = format!("docker.io/x@sha256:{digest}\nregistry.cloudflare.com/acc/other@sha256:{digest}\nregistry.cloudflare.com/acc/g1t-web@sha256:{digest}\n");
2083 assert_eq!(
2084 pushed_digest(&lines, "registry.cloudflare.com", "acc/g1t-web"),
2085 Some(format!("registry.cloudflare.com/acc/g1t-web@sha256:{digest}"))
2086 );
2087 assert_eq!(pushed_digest("registry.cloudflare.com/acc/g1t-web@sha256:short", "registry.cloudflare.com", "acc/g1t-web"), None);
2088 let grant: RegistryGrant = serde_json::from_value(json!({
2089 "registry": "registry.cloudflare.com", "repository": "acc/g1t-web", "tag": "dpl_1",
2090 "username": "u", "password": "p", "max_image_bytes": 2_000_000_000u64, "instance_type": "lite"
2091 }))
2092 .unwrap();
2093 assert_eq!((grant.max_image_bytes, grant.instance_type.as_deref()), (2_000_000_000, Some("lite")));
2094 assert_eq!(size_text(1_450_000_000), "1.4 GB");
2095 }
2096
2097 /// What a container g1t writes the Dockerfile for is built with.
2098 const GENERATED: &str = "docker build -f Dockerfile (written by g1t) .";
2099
2100 fn frameworks_hugo() -> &'static str {
2101 "if command -v hugo >/dev/null 2>&1; then hugo --minify; else npx --yes hugo-extended@0.166.0 --minify; fi"
2102 }
2103
2104 fn frameworks_mdbook() -> &'static str {
2105 "if ! command -v mdbook >/dev/null 2>&1; then cargo install mdbook --version 0.5.4 --locked --quiet; fi && mdbook build"
2106 }
2107
2108 #[test]
2109 fn jekyll_installs_its_gems_after_the_packages() {
2110 let dir = project("jekyll-install", &[("_config.yml", ""), ("Gemfile", ""), ("package.json", "{}")]);
2111 let found = detect(&dir, None, None).unwrap();
2112 assert_eq!(found.install.as_deref(), Some("npm install && bundle config set --local path vendor/bundle && bundle install"));
2113 }
2114
2115 #[test]
2116 fn an_adapters_worker_is_found_once_built_and_never_served() {
2117 let dir = project(
2118 "adapter",
2119 &[
2120 ("dist/_worker.js/index.js", "export default {}"),
2121 ("dist/_routes.json", "{}"),
2122 ("dist/.assetsignore", "_worker.js\nsecret.txt\n"),
2123 ("dist/secret.txt", "no"),
2124 ("dist/index.html", "<h1>hi</h1>"),
2125 ("dist/about/_worker.js", "a page's own file"),
2126 ("kit/.svelte-kit/cloudflare/_worker.js", "export default {}"),
2127 ],
2128 );
2129 let astro = Adapter { entry: "dist/_worker.js", assets: "dist" };
2130 assert_eq!(adapter_entry(&dir, &astro), Some(dir.join("dist/_worker.js/index.js")));
2131 let kit = Adapter { entry: ".svelte-kit/cloudflare/_worker.js", assets: ".svelte-kit/cloudflare" };
2132 assert_eq!(adapter_entry(&dir.join("kit"), &kit), Some(dir.join("kit/.svelte-kit/cloudflare/_worker.js")));
2133 assert_eq!(adapter_entry(&dir.join("missing"), &kit), None);
2134 let mut served: Vec<String> = collect_site(&dir.join("dist"), false).unwrap().into_iter().map(|a| a.path).collect();
2135 served.sort();
2136 assert_eq!(served, ["/about/_worker.js", "/index.html"]);
2137 }
2138
2139 #[test]
2140 fn a_config_the_build_wrote_is_read_in_place_of_the_projects() {
2141 let dir = project(
2142 "redirected",
2143 &[
2144 ("wrangler.jsonc", r#"{ "main": "worker/index.ts" }"#),
2145 (".wrangler/deploy/config.json", r#"{ "configPath": "../../dist/app/wrangler.json" }"#),
2146 ("dist/app/wrangler.json", r#"{ "main": "index.js", "assets": { "directory": "../client" } }"#),
2147 ],
2148 );
2149 let (config, base) = redirected_config(&dir).unwrap().unwrap();
2150 assert_eq!(config.main.as_deref(), Some("index.js"));
2151 assert_eq!(base, dir.join("dist/app").canonicalize().unwrap());
2152 assert!(base.join(config.assets.unwrap().directory.unwrap()).ends_with("dist/app/../client"));
2153 assert!(redirected_config(&project("not-redirected", &[])).unwrap().is_none());
2154 }
2155
2156 #[test]
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r92157 fn the_root_directory_stays_inside_the_checkout() {
2158 let base = Path::new("/w/repo");
2159 assert_eq!(project_dir_in(base, ""), base);
2160 assert_eq!(project_dir_in(base, "."), base);
2161 assert_eq!(project_dir_in(base, "/apps/site/"), base.join("apps/site"));
2162 assert_eq!(project_dir_in(base, "../etc"), base);
2163 assert_eq!(project_dir_in(base, "apps/../../x"), base);
2164 }
2165
2166 #[test]
2167 fn a_step_prints_the_log_redacted() {
2168 let mut log = Log { echo: Some(vec!["hunter22".to_owned()]), ..Log::default() };
2169 log.line("token hunter22 used");
2170 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.2171 assert_eq!(wrangler_build(Some(Path::new("/tmp/b")), None), "npx --yes wrangler@4 deploy --dry-run --outdir /tmp/b");
2172 assert_eq!(
2173 wrangler_build(Some(Path::new("/tmp/b")), Some(Path::new("/tmp/w.json"))),
2174 "npx --yes wrangler@4 deploy --dry-run --config /tmp/w.json --outdir /tmp/b"
2175 );
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r92176 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put2177}

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