flagon-io/g1t

public

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

g1t/crates/runner/src/deploy.rs

723 lines26,512 bytesCodeBlame

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.

Deployments: a preview for every pull request, production on g1t.page1//! 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.
Projects: what a workspace builds and runs, first on every page25//! - `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.
Secrets and variables: one list, rows per environment, for workflows and deployments28//! - `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.
Deployments: a preview for every pull request, production on g1t.page31
32use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::time::Instant;
35
36use anyhow::{Context, Result, bail};
37use base64::Engine;
38use base64::engine::general_purpose::STANDARD;
39use serde::{Deserialize, Serialize};
40use serde_json::{Value, json};
41use sha2::{Digest, Sha256};
42
43use crate::checks::{redact, run_command};
44use crate::{WORKDIR, auth_option, env, git};
45
46/// Where `wrangler deploy --dry-run` writes the bundle.
47const BUNDLE_DIR: &str = "/work/g1t-bundle";
48/// Cloudflare's limits on a Worker's static assets.
49const MAX_FILES: usize = 20_000;
50const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;
51/// How much of the build's output is kept for the deployment's log.
52const MAX_LOG_CHARS: usize = 20_000;
53/// Directories a static build usually writes to, in the order they are tried.
54const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"];
55/// Bindings a Workers project may declare that are not provisioned yet.
56const UNSUPPORTED_BINDINGS: [&str; 10] = [
57 "kv_namespaces",
58 "d1_databases",
59 "r2_buckets",
60 "durable_objects",
61 "services",
62 "queues",
63 "vectorize",
64 "hyperdrive",
65 "ai",
66 "workflows",
67];
68
69struct Reporter {
70 base: String,
71 token: String,
72}
73
74impl Reporter {
75 fn send(&self, step: &str, mut body: Value) -> Result<Value> {
76 body["token"] = self.token.clone().into();
77 let response = ureq::post(&format!("{}/{step}", self.base))
78 .send_json(body)
79 .with_context(|| format!("could not report `{step}` to g1t"))?;
80 Ok(response.into_json().unwrap_or(Value::Null))
81 }
82}
83
84/// The build's log, kept to its end.
85#[derive(Default)]
86struct Log {
87 text: String,
88}
89
90impl Log {
91 fn line(&mut self, line: &str) {
92 self.text.push_str(line);
93 self.text.push('\n');
94 }
95
96 fn tail(&self) -> String {
97 let length = self.text.chars().count();
98 if length <= MAX_LOG_CHARS {
99 return self.text.clone();
100 }
101 let kept: String = self.text.chars().skip(length - MAX_LOG_CHARS).collect();
102 format!("… (earlier output not shown)\n{kept}")
103 }
104}
105
106/// Runs a command in the checkout, logging it; fails if it fails.
107fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> {
108 log.line(&format!("$ {command}"));
Projects: what a workspace builds and runs, first on every page109 let result = run_command(command, &project_dir(), secrets);
Deployments: a preview for every pull request, production on g1t.page110 if !result.output_text().is_empty() {
111 log.line(result.output_text());
112 }
113 if !result.passed {
114 bail!("`{command}` failed");
115 }
116 Ok(())
117}
118
119/// A Workers project's settings, from whichever config file it has.
120#[derive(Debug, Default, Deserialize)]
121struct WranglerConfig {
122 main: Option<String>,
123 compatibility_date: Option<String>,
124 #[serde(default)]
125 compatibility_flags: Vec<String>,
126 assets: Option<AssetsConfig>,
127 #[serde(default)]
128 vars: BTreeMap<String, Value>,
129 #[serde(flatten)]
130 rest: BTreeMap<String, Value>,
131}
132
133#[derive(Debug, Default, Deserialize)]
134struct AssetsConfig {
135 directory: Option<String>,
136 binding: Option<String>,
137 html_handling: Option<String>,
138 not_found_handling: Option<String>,
139}
140
141/// JSON with comments and trailing commas, as `wrangler.jsonc` allows.
142fn strip_jsonc(text: &str) -> String {
143 let mut out = String::with_capacity(text.len());
144 let mut chars = text.chars().peekable();
145 let mut in_string = false;
146 while let Some(c) = chars.next() {
147 if in_string {
148 out.push(c);
149 if c == '\\' {
150 if let Some(next) = chars.next() {
151 out.push(next);
152 }
153 } else if c == '"' {
154 in_string = false;
155 }
156 continue;
157 }
158 match (c, chars.peek()) {
159 ('"', _) => {
160 in_string = true;
161 out.push(c);
162 }
163 ('/', Some('/')) => {
164 for c in chars.by_ref() {
165 if c == '\n' {
166 out.push('\n');
167 break;
168 }
169 }
170 }
171 ('/', Some('*')) => {
172 chars.next();
173 let mut last = ' ';
174 for c in chars.by_ref() {
175 if last == '*' && c == '/' {
176 break;
177 }
178 last = c;
179 }
180 }
181 _ => out.push(c),
182 }
183 }
184 // Trailing commas before a closing bracket.
185 let mut cleaned = String::with_capacity(out.len());
186 let chars: Vec<char> = out.chars().collect();
187 let mut in_string = false;
188 let mut i = 0;
189 while i < chars.len() {
190 let c = chars[i];
191 if c == '"' && (i == 0 || chars[i - 1] != '\\') {
192 in_string = !in_string;
193 }
194 if c == ',' && !in_string {
195 let next = chars[i + 1..].iter().find(|c| !c.is_whitespace());
196 if matches!(next, Some('}') | Some(']')) {
197 i += 1;
198 continue;
199 }
200 }
201 cleaned.push(c);
202 i += 1;
203 }
204 cleaned
205}
206
207fn read_wrangler(dir: &Path) -> Result<Option<WranglerConfig>> {
208 for name in ["wrangler.jsonc", "wrangler.json"] {
209 let path = dir.join(name);
210 if path.exists() {
211 let text = std::fs::read_to_string(&path)?;
212 return Ok(Some(
213 serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {name}"))?,
214 ));
215 }
216 }
217 let path = dir.join("wrangler.toml");
218 if path.exists() {
219 let text = std::fs::read_to_string(&path)?;
220 return Ok(Some(toml::from_str(&text).context("could not read wrangler.toml")?));
221 }
222 Ok(None)
223}
224
225/// How to install the project's dependencies, judged by its lockfile.
226fn install_command(dir: &Path) -> Option<&'static str> {
227 if !dir.join("package.json").exists() {
228 return None;
229 }
230 Some(if dir.join("pnpm-lock.yaml").exists() {
231 "corepack enable && pnpm install --frozen-lockfile"
232 } else if dir.join("yarn.lock").exists() {
233 "corepack enable && yarn install"
234 } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
235 "npx --yes bun install"
236 } else if dir.join("package-lock.json").exists() {
237 "npm ci"
238 } else {
239 "npm install"
240 })
241}
242
243fn has_build_script(dir: &Path) -> bool {
244 std::fs::read_to_string(dir.join("package.json"))
245 .ok()
246 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
247 .is_some_and(|package| package["scripts"]["build"].is_string())
248}
249
250/// One file of the site, as Cloudflare's asset upload names it.
251struct Asset {
252 path: String,
253 hash: String,
254 size: u64,
255 file: PathBuf,
256}
257
258fn content_type(path: &str) -> &'static str {
259 let extension = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
260 match extension.as_str() {
261 "html" | "htm" => "text/html",
262 "css" => "text/css",
263 "js" | "mjs" => "application/javascript",
264 "json" | "map" => "application/json",
265 "svg" => "image/svg+xml",
266 "png" => "image/png",
267 "jpg" | "jpeg" => "image/jpeg",
268 "gif" => "image/gif",
269 "webp" => "image/webp",
270 "avif" => "image/avif",
271 "ico" => "image/x-icon",
272 "woff" => "font/woff",
273 "woff2" => "font/woff2",
274 "ttf" => "font/ttf",
275 "txt" => "text/plain",
276 "xml" => "application/xml",
277 "wasm" => "application/wasm",
278 "pdf" => "application/pdf",
279 "mp4" => "video/mp4",
280 "webm" => "video/webm",
281 _ => "application/octet-stream",
282 }
283}
284
285/// Every file under `root`, but for what never belongs in a site.
286fn collect(root: &Path, dir: &Path, skip_project: bool, out: &mut Vec<Asset>) -> Result<()> {
287 for entry in std::fs::read_dir(dir)? {
288 let entry = entry?;
289 let name = entry.file_name().to_string_lossy().into_owned();
290 let path = entry.path();
291 let kind = entry.file_type()?;
292 if name == ".git" || (skip_project && (name == "node_modules" || name.starts_with(".g1t"))) {
293 continue;
294 }
295 if kind.is_dir() {
296 collect(root, &path, skip_project, out)?;
297 continue;
298 }
299 if !kind.is_file() || name == "_headers" || name == "_redirects" {
300 continue;
301 }
302 let size = entry.metadata()?.len();
303 let relative = path
304 .strip_prefix(root)?
305 .to_string_lossy()
306 .replace('\\', "/");
307 if size > MAX_FILE_BYTES {
308 bail!("{relative} is larger than Cloudflare's 25 MiB limit for one file");
309 }
310 let bytes = std::fs::read(&path)?;
311 let digest = hex::encode(Sha256::digest(&bytes));
312 out.push(Asset {
313 path: format!("/{relative}"),
314 hash: digest[..32].to_owned(),
315 size,
316 file: path,
317 });
318 if out.len() > MAX_FILES {
319 bail!("the site has more than {MAX_FILES} files, Cloudflare's limit");
320 }
321 }
322 Ok(())
323}
324
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API325/// The upload, as the API sends it: `snake_case`, or `uploadUrl` from
326/// before its bodies were.
Deployments: a preview for every pull request, production on g1t.page327#[derive(Deserialize)]
328struct UploadSession {
329 jwt: String,
330 #[serde(default)]
331 buckets: Vec<Vec<String>>,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API332 #[serde(alias = "uploadUrl")]
Deployments: a preview for every pull request, production on g1t.page333 upload_url: String,
334}
335
336/// Sends one bucket of files with the upload's key. The last bucket's
337/// answer carries the key that completes the upload.
338fn upload_bucket(session: &UploadSession, bucket: &[String], by_hash: &BTreeMap<&str, &Asset>) -> Result<Option<String>> {
339 let boundary = format!("g1t-{}", hex::encode(Sha256::digest(bucket.join(",").as_bytes()))[..24].to_owned());
340 let mut body: Vec<u8> = Vec::new();
341 for hash in bucket {
342 let asset = by_hash
343 .get(hash.as_str())
344 .with_context(|| format!("Cloudflare asked for a file this build does not have ({hash})"))?;
345 let bytes = std::fs::read(&asset.file)?;
346 body.extend_from_slice(
347 format!(
348 "--{boundary}\r\nContent-Disposition: form-data; name=\"{hash}\"; filename=\"{hash}\"\r\nContent-Type: {}\r\n\r\n",
349 content_type(&asset.path)
350 )
351 .as_bytes(),
352 );
353 body.extend_from_slice(STANDARD.encode(bytes).as_bytes());
354 body.extend_from_slice(b"\r\n");
355 }
356 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
357 let response = ureq::post(&session.upload_url)
358 .set("authorization", &format!("Bearer {}", session.jwt))
359 .set("content-type", &format!("multipart/form-data; boundary={boundary}"))
360 .send_bytes(&body);
361 let response = match response {
362 Ok(response) => response,
363 Err(ureq::Error::Status(code, response)) => {
364 bail!("Cloudflare refused the upload ({code}): {}", response.into_string().unwrap_or_default())
365 }
366 Err(error) => bail!("could not upload to Cloudflare: {error}"),
367 };
368 let answer: Value = response.into_json().unwrap_or(Value::Null);
369 Ok(answer["result"]["jwt"].as_str().map(str::to_owned))
370}
371
372#[derive(Serialize)]
373#[serde(rename_all = "camelCase")]
374struct Module {
375 name: String,
376 content_base64: String,
377 content_type: String,
378}
379
380/// The bundle `wrangler deploy --dry-run` wrote, main module first.
381fn bundle_modules(main: &str) -> Result<(String, Vec<Module>)> {
382 let stem = Path::new(main)
383 .file_stem()
384 .map(|stem| stem.to_string_lossy().into_owned())
385 .unwrap_or_else(|| "index".to_owned());
386 let mut modules = Vec::new();
387 let mut files = Vec::new();
388 collect_files(Path::new(BUNDLE_DIR), &mut files)?;
389 for file in files {
390 let name = file
391 .strip_prefix(BUNDLE_DIR)?
392 .to_string_lossy()
393 .replace('\\', "/");
394 let kind = match name.rsplit('.').next().unwrap_or("") {
395 "js" | "mjs" => "application/javascript+module",
396 "wasm" => "application/wasm",
397 "map" | "md" => continue,
398 _ => "text/plain",
399 };
400 modules.push(Module {
401 content_base64: STANDARD.encode(std::fs::read(&file)?),
402 content_type: kind.to_owned(),
403 name,
404 });
405 }
406 let main_name = modules
407 .iter()
408 .map(|module| module.name.clone())
409 .find(|name| *name == format!("{stem}.js") || *name == format!("{stem}.mjs"))
410 .or_else(|| {
411 modules
412 .iter()
413 .find(|module| module.content_type == "application/javascript+module")
414 .map(|module| module.name.clone())
415 })
416 .context("wrangler wrote no JavaScript module")?;
417 Ok((main_name, modules))
418}
419
420fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
421 for entry in std::fs::read_dir(dir)? {
422 let entry = entry?;
423 if entry.file_type()?.is_dir() {
424 collect_files(&entry.path(), out)?;
425 } else {
426 out.push(entry.path());
427 }
428 }
429 Ok(())
430}
431
432/// What was built: the Worker's code and settings, and where its site is.
433struct Built {
434 worker: Value,
435 assets_dir: Option<PathBuf>,
436 warnings: Vec<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look437 /// What kind of project it was found to be, for its settings page:
438 /// `workers`, `static` (a build's output) or `html` (the files as they are).
439 detected: &'static str,
Deployments: a preview for every pull request, production on g1t.page440}
441
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look442/// What a project without a Workers config is: a site it built, or its own
443/// files served as they are.
444fn static_kind(project: &Path, assets_dir: &Path, built: bool) -> &'static str {
445 if !built && assets_dir == project { "html" } else { "static" }
446}
447
Deployments: a preview for every pull request, production on g1t.page448fn build(log: &mut Log, secrets: &[String]) -> Result<Built> {
Projects: what a workspace builds and runs, first on every page449 let project = project_dir();
450 let dir = project.as_path();
Deployments: a preview for every pull request, production on g1t.page451 let config = read_wrangler(dir)?;
452 let custom_build = std::env::var("BUILD_COMMAND").ok().filter(|c| !c.trim().is_empty());
453 if let Some(install) = install_command(dir) {
454 step(log, install, secrets)?;
455 }
456 let mut warnings = Vec::new();
457 match config {
458 Some(config) => {
459 if let Some(command) = &custom_build {
460 step(log, command, secrets)?;
461 }
462 for binding in UNSUPPORTED_BINDINGS {
463 if config.rest.get(binding).is_some_and(|value| !value.is_null()) {
464 warnings.push(format!(
465 "`{binding}` is not provisioned on g1t.page yet, so the app runs without it."
466 ));
467 }
468 }
469 let mut worker = json!({
470 "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| "2026-09-26".to_owned()),
471 "compatibilityFlags": config.compatibility_flags,
472 "vars": config.vars,
473 });
474 if let Some(main) = &config.main {
475 // `--dry-run` runs the project's own build and bundles it,
476 // without deploying anywhere.
477 step(
478 log,
479 &format!("npx --yes wrangler@4 deploy --dry-run --outdir {BUNDLE_DIR}"),
480 secrets,
481 )?;
482 let (main_module, modules) = bundle_modules(main)?;
483 worker["mainModule"] = main_module.into();
484 worker["modules"] = serde_json::to_value(modules)?;
485 }
486 let assets = config.assets.unwrap_or_default();
487 let assets_dir = assets.directory.as_ref().map(|directory| dir.join(directory));
488 worker["assetsBinding"] = assets.binding.into();
489 worker["htmlHandling"] = assets.html_handling.into();
490 worker["notFoundHandling"] = assets.not_found_handling.into();
491 Ok(Built {
492 worker,
493 assets_dir,
494 warnings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look495 detected: "workers",
Deployments: a preview for every pull request, production on g1t.page496 })
497 }
498 None => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look499 let built = custom_build.is_some() || has_build_script(dir);
Deployments: a preview for every pull request, production on g1t.page500 if let Some(command) = &custom_build {
501 step(log, command, secrets)?;
502 } else if has_build_script(dir) {
503 step(log, "npm run build", secrets)?;
504 }
505 let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty());
506 let assets_dir = match chosen {
507 Some(chosen) => {
508 let path = dir.join(chosen.trim_matches('/'));
509 if !path.is_dir() {
510 bail!("the output directory `{chosen}` does not exist after the build");
511 }
512 path
513 }
514 None => OUTPUT_DIRS
515 .iter()
516 .map(|name| dir.join(name))
517 .find(|path| path.join("index.html").exists() || (path.is_dir() && path != &dir.join("public")))
518 .or_else(|| dir.join("index.html").exists().then(|| dir.to_path_buf()))
519 .context(
520 "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public",
521 )?,
522 };
523 let spa = !assets_dir.join("404.html").exists();
524 Ok(Built {
525 worker: json!({
526 "compatibilityDate": "2026-09-26",
527 "compatibilityFlags": [],
528 "vars": {},
529 "notFoundHandling": if spa { "single-page-application" } else { "404-page" },
530 }),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look531 detected: static_kind(dir, &assets_dir, built),
Deployments: a preview for every pull request, production on g1t.page532 assets_dir: Some(assets_dir),
533 warnings,
534 })
535 }
536 }
537}
538
Projects: what a workspace builds and runs, first on every page539/// Where the project lives in the checkout: its root directory.
540fn project_dir() -> PathBuf {
541 let root = std::env::var("ROOT_DIR").unwrap_or_default();
542 let root = root.trim_matches('/');
543 if root.is_empty() || root.split('/').any(|part| part == "..") {
544 PathBuf::from(WORKDIR)
545 } else {
546 Path::new(WORKDIR).join(root)
547 }
548}
549
Deployments: a preview for every pull request, production on g1t.page550fn check_out(secrets: &[String]) -> Result<()> {
551 let remote = env("GIT_REMOTE")?;
552 let commit = env("GIT_COMMIT")?;
553 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
554 std::fs::create_dir_all("/work")?;
555 let cloned = git(Path::new("/work"), &["-c", &auth, "clone", "--quiet", &remote, WORKDIR]).and_then(|_| {
556 git(
557 Path::new(WORKDIR),
558 &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit],
559 )
560 });
561 if let Err(error) = cloned {
562 bail!("{}", redact(&format!("{error:#}"), secrets));
563 }
564 Ok(())
565}
566
567fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
568 check_out(secrets).context("the commit could not be checked out")?;
Secrets and variables: one list, rows per environment, for workflows and deployments569 // The repository's variables and secrets for deploy builds.
570 for source in ["BUILD_ENV", "BUILD_SECRETS"] {
571 if let Ok(vars) = std::env::var(source)
572 && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
573 {
574 for (name, value) in vars {
575 if let Some(value) = value.as_str() {
576 // SAFETY: single-threaded; set before any command runs.
577 unsafe { std::env::set_var(name, value) };
578 }
Deployments: a preview for every pull request, production on g1t.page579 }
580 }
581 }
582 let built = build(log, secrets)?;
583 let mut finish = json!({
584 "worker": built.worker,
585 "warnings": built.warnings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look586 "detected": built.detected,
Deployments: a preview for every pull request, production on g1t.page587 });
588 if let Some(dir) = &built.assets_dir {
Projects: what a workspace builds and runs, first on every page589 let skip_project = *dir == project_dir();
Deployments: a preview for every pull request, production on g1t.page590 let mut assets = Vec::new();
591 collect(dir, dir, skip_project, &mut assets)?;
592 if assets.is_empty() {
593 bail!("the site to serve is empty");
594 }
595 log.line(&format!("Uploading {} files.", assets.len()));
596 for special in ["_headers", "_redirects"] {
597 if let Ok(text) = std::fs::read_to_string(dir.join(special)) {
598 finish["worker"][special] = text.into();
599 }
600 }
601 let manifest: BTreeMap<&str, Value> = assets
602 .iter()
603 .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size })))
604 .collect();
605 let answer = reporter.send("session", json!({ "manifest": manifest }))?;
606 if answer["ok"] == false {
607 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload"));
608 }
609 let session: UploadSession =
610 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?;
611 let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect();
612 let mut completion = session.jwt.clone();
613 for bucket in &session.buckets {
614 if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? {
615 completion = jwt;
616 }
617 }
618 finish["completionJwt"] = completion.into();
619 }
620 Ok(finish)
621}
622
623pub fn main() -> i32 {
624 let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
625 (Ok(api), Ok(id), Ok(token)) => Reporter {
626 base: format!("{api}/deployments/jobs/{id}"),
627 token,
628 },
629 _ => {
630 eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set");
631 return 2;
632 }
633 };
Secrets and variables: one list, rows per environment, for workflows and deployments634 let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
Deployments: a preview for every pull request, production on g1t.page635 .iter()
636 .filter_map(|name| std::env::var(name).ok())
637 .filter(|secret| !secret.is_empty())
638 .collect();
Secrets and variables: one list, rows per environment, for workflows and deployments639 // The repository's build secrets never appear in the log.
640 if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) {
641 secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned));
642 }
Deployments: a preview for every pull request, production on g1t.page643 if let Err(error) = reporter.send("started", json!({})) {
644 eprintln!("g1t-runner: {error:#}");
645 return 1;
646 }
647 let started = Instant::now();
648 let mut log = Log::default();
649 let outcome = deploy(&reporter, &mut log, &secrets);
650 let seconds = started.elapsed().as_secs();
651 let sent = match outcome {
652 Ok(mut finish) => {
653 finish["log"] = redact(&log.tail(), &secrets).into();
654 finish["buildSeconds"] = seconds.into();
655 reporter.send("finish", finish)
656 }
657 Err(error) => {
658 let message = redact(&format!("{error:#}"), &secrets);
659 log.line(&format!("The build failed: {message}"));
660 reporter.send(
661 "fail",
662 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
663 )
664 }
665 };
666 match sent {
667 Ok(_) => 0,
668 Err(error) => {
669 eprintln!("g1t-runner: {error:#}");
670 1
671 }
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API680 fn the_upload_session_is_read_in_either_spelling() {
681 for answer in [
682 json!({ "jwt": "j", "buckets": [["a"]], "upload_url": "https://u" }),
683 json!({ "jwt": "j", "uploadUrl": "https://u" }),
684 ] {
685 let session: UploadSession = serde_json::from_value(answer).unwrap();
686 assert_eq!(session.upload_url, "https://u");
687 }
688 }
689
690 #[test]
Deployments: a preview for every pull request, production on g1t.page691 fn jsonc_comments_and_trailing_commas_are_dropped() {
692 let text = r#"{
693 // a comment
694 "main": "src/index.ts", /* another */
695 "vars": { "URL": "https://x.dev//not-a-comment", },
696 }"#;
697 let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap();
698 assert_eq!(config.main.as_deref(), Some("src/index.ts"));
699 assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment");
700 }
701
702 #[test]
703 fn unsupported_bindings_are_noticed() {
704 let config: WranglerConfig =
705 serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap();
706 assert!(config.rest.contains_key("d1_databases"));
707 }
708
709 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look710 fn a_site_is_plain_html_only_when_its_own_files_are_served_unbuilt() {
711 let project = Path::new("/w/site");
712 assert_eq!(static_kind(project, project, false), "html");
713 assert_eq!(static_kind(project, project, true), "static");
714 assert_eq!(static_kind(project, &project.join("dist"), false), "static");
715 }
716
717 #[test]
Deployments: a preview for every pull request, production on g1t.page718 fn files_are_typed_by_extension() {
719 assert_eq!(content_type("/index.HTML"), "text/html");
720 assert_eq!(content_type("/a/b.woff2"), "font/woff2");
721 assert_eq!(content_type("/LICENSE"), "application/octet-stream");
722 }
723}