Skip to content
1,039 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.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1//! 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.
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API40
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
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.55/// Where `wrangler deploy --dry-run` writes the bundle in a sandbox.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API56const 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
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.93/// The build's log, kept to its end; also printed, in a workflow step.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API94#[derive(Default)]
95struct Log {
96 text: String,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.97 /// Each line to standard output as well, redacted.
98 echo: Option<Vec<String>>,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API99}
100
101impl Log {
102 fn line(&mut self, line: &str) {
103 self.text.push_str(line);
104 self.text.push('\n');
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.105 if let Some(secrets) = &self.echo {
106 println!("{}", redact(line, secrets));
107 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API108 }
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.
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.395fn bundle_modules(main: &str, bundle: &Path) -> Result<(String, Vec<Module>)> {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API396 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();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.402 collect_files(bundle, &mut files)?;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API403 for file in files {
404 let name = file
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.405 .strip_prefix(bundle)?
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API406 .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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily446/// 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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API471/// 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>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look476 /// 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,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API479}
480
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look481/// 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
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API552fn build(log: &mut Log, secrets: &[String]) -> Result<Built> {
553 let project = project_dir();
554 let dir = project.as_path();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.555 let bundle = bundle_dir();
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API556 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 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily567 warnings.extend(config_warnings(&config));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API568 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.
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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)?;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API579 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look591 detected: "workers",
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API592 })
593 }
594 None => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look595 let built = custom_build.is_some() || has_build_script(dir);
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API596 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 }
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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 },
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API616 };
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 }),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look625 detected: static_kind(dir, &assets_dir, built),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API626 assets_dir: Some(assets_dir),
627 warnings,
628 })
629 }
630 }
631}
632
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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() {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API643 PathBuf::from(WORKDIR)
644 } else {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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)
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API667 }
668}
669
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API675fn 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")?;
Previews build from any branch; Agent in place of Ask AI; pin from the sidebar680 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]));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API687 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> {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API703 // 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look720 "detected": built.detected,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API721 });
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.722 for warning in &built.warnings {
723 log.line(&format!("Warning: {warning}"));
724 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API725 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
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.760/// A sandbox's build (`MODE=deploy`).
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API761pub fn main() -> i32 {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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 {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API785 let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
786 (Ok(api), Ok(id), Ok(token)) => Reporter {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.787 base: format!("{}/deployments/jobs/{id}", api.trim_end_matches('/')),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API788 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();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.809 let mut log = Log { echo: step_log.then(|| secrets.clone()), ..Log::default() };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API810 let outcome = deploy(&reporter, &mut log, &secrets);
811 let seconds = started.elapsed().as_secs();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.812 if step_log {
813 let _ = std::fs::remove_dir_all(bundle_dir());
814 }
815 let (sent, failed) = match outcome {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API816 Ok(mut finish) => {
817 finish["log"] = redact(&log.tail(), &secrets).into();
818 finish["buildSeconds"] = seconds.into();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.819 (reporter.send("finish", finish), false)
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API820 }
821 Err(error) => {
822 let message = redact(&format!("{error:#}"), &secrets);
823 log.line(&format!("The build failed: {message}"));
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.824 let sent = reporter.send(
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API825 "fail",
826 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.827 );
828 (sent, true)
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API829 }
830 };
831 match sent {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API839 Ok(_) => 0,
840 Err(error) => {
841 eprintln!("g1t-runner: {error:#}");
842 1
843 }
844 }
845}
846
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API895#[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]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily930 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]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look948 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]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API956 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 }
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1039}

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