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