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 API | 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. | |
| 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 API | 40 | |
| 41 | use std::collections::BTreeMap; | |
| 42 | use std::path::{Path, PathBuf}; | |
| 43 | use std::time::Instant; | |
| 44 | ||
| 45 | use anyhow::{Context, Result, bail}; | |
| 46 | use base64::Engine; | |
| 47 | use base64::engine::general_purpose::STANDARD; | |
| 48 | use serde::{Deserialize, Serialize}; | |
| 49 | use serde_json::{Value, json}; | |
| 50 | use sha2::{Digest, Sha256}; | |
| 51 | ||
| 52 | use crate::checks::{redact, run_command}; | |
| 53 | use 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 API | 56 | const BUNDLE_DIR: &str = "/work/g1t-bundle"; |
| 57 | /// Cloudflare's limits on a Worker's static assets. | |
| 58 | const MAX_FILES: usize = 20_000; | |
| 59 | const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024; | |
| 60 | /// How much of the build's output is kept for the deployment's log. | |
| 61 | const MAX_LOG_CHARS: usize = 20_000; | |
| 62 | /// Directories a static build usually writes to, in the order they are tried. | |
| 63 | const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"]; | |
| 64 | /// Bindings a Workers project may declare that are not provisioned yet. | |
| 65 | const 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 | ||
| 78 | struct Reporter { | |
| 79 | base: String, | |
| 80 | token: String, | |
| 81 | } | |
| 82 | ||
| 83 | impl 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 API | 94 | #[derive(Default)] |
| 95 | struct 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 API | 99 | } |
| 100 | ||
| 101 | impl 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 API | 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. | |
| 121 | fn 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)] | |
| 135 | struct 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)] | |
| 148 | struct 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. | |
| 156 | fn 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 | ||
| 221 | fn 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. | |
| 240 | fn 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 | ||
| 257 | fn 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. | |
| 265 | struct Asset { | |
| 266 | path: String, | |
| 267 | hash: String, | |
| 268 | size: u64, | |
| 269 | file: PathBuf, | |
| 270 | } | |
| 271 | ||
| 272 | fn 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. | |
| 300 | fn 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)] | |
| 342 | struct 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. | |
| 352 | fn 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")] | |
| 388 | struct 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. | 395 | fn 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 API | 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(); | |
| 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 API | 403 | 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 API | 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 | ||
| 434 | fn 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 daily | 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. | |
| 449 | fn 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 API | 471 | /// What was built: the Worker's code and settings, and where its site is. |
| 472 | struct 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 look | 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, | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 479 | } |
| 480 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 481 | /// What a project without a Workers config is: a site it built, or its own |
| 482 | /// files served as they are. | |
| 483 | fn 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. | |
| 489 | fn 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. | |
| 498 | fn 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)] | |
| 515 | pub(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 | ||
| 523 | pub(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 API | 552 | fn 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 API | 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 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 567 | warnings.extend(config_warnings(&config)); |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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. | |
| 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 API | 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, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 591 | detected: "workers", |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 592 | }) |
| 593 | } | |
| 594 | None => { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 595 | 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 API | 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 | } | |
| 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 API | 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 | }), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 625 | 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 API | 626 | 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. | |
| 635 | fn 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. | |
| 641 | fn 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 API | 643 | 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. | |
| 651 | fn 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. | |
| 661 | fn 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 API | 667 | } |
| 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. |
| 671 | fn 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 API | 675 | fn 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 sidebar | 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])); | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 687 | if let Err(error) = cloned { |
| 688 | bail!("{}", redact(&format!("{error:#}"), secrets)); | |
| 689 | } | |
| 690 | Ok(()) | |
| 691 | } | |
| 692 | ||
| 693 | fn 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 API | 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, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 720 | "detected": built.detected, |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 721 | }); |
| 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 API | 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 | ||
| 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 API | 761 | pub 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. | |
| 768 | pub 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 | ||
| 776 | const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page. | |
| 777 | ||
| 778 | Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds), | |
| 779 | G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR, | |
| 780 | BUILD_ENV and BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. | |
| 781 | ||
| 782 | g1t-runner detect prints what the build would find, as key=value lines."; | |
| 783 | ||
| 784 | fn run(step_log: bool) -> i32 { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 785 | 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 API | 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(); | |
| 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 API | 810 | 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 API | 816 | 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 API | 820 | } |
| 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 API | 825 | "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 API | 829 | } |
| 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 API | 839 | 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`. | |
| 851 | pub 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. | |
| 884 | fn 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 API | 895 | #[cfg(test)] |
| 896 | mod 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 daily | 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] | |
| 942 | crons = [\"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 look | 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] | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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 | } | |
| 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 API | 1039 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.