Skip to content
612 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.

Fast pages, required checks on the branch, self-hosted runners, honest incidents1//! `g1t-runner` on your own machine: a self-hosted runner for g1t.
2//!
3//! ```text
4//! g1t-runner register --url https://g1t.sh --token g1trt_… [--name build-01]
5//! [--labels gpu,cuda] [--group Default] [--ephemeral] [--work-dir DIR]
6//! [--no-docker] [--image IMAGE] [--agent-image IMAGE] [--replace]
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.7//! [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
Fast pages, required checks on the branch, self-hosted runners, honest incidents8//! g1t-runner run [--once]
9//! g1t-runner service install|start|stop|status|uninstall
10//! g1t-runner remove
11//! g1t-runner update
12//! g1t-runner version
13//! ```
14//!
15//! It only ever calls out, to `api.g1t.sh` over HTTPS: it polls for work
16//! (a poll waits up to 20 seconds for some, and is the runner's heartbeat),
17//! runs what it is given with the same harness g1t's sandboxes run (this
18//! program in another mode, see `exec`), and says how it ended. Nothing
19//! listens on the machine. Every command takes `--dir` (default
20//! `~/.g1t-runner`, or `G1T_RUNNER_DIR`), where its configuration is kept.
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.21//!
22//! How it keeps jobs from the machine is `sandbox`: in its image under
23//! Sysbox (`G1T_RUNNER_SANDBOX=nested`) it runs its own Docker Engine
24//! (`engine`) and refuses to start if it can reach the machine's.
Fast pages, required checks on the branch, self-hosted runners, honest incidents25
26mod api;
27mod config;
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.28#[cfg(unix)]
29mod engine;
Fast pages, required checks on the branch, self-hosted runners, honest incidents30mod exec;
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.31mod sandbox;
Fast pages, required checks on the branch, self-hosted runners, honest incidents32mod service;
33mod update;
34
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::time::{Duration, Instant};
38
39use anyhow::{Context, Result, bail};
40use serde_json::json;
41
42use api::{Api, Failure};
43use config::Config;
44
45pub const VERSION: &str = env!("CARGO_PKG_VERSION");
46
47/// Set to stop the loop between polls: a Windows service's stop request.
48pub static STOP: AtomicBool = AtomicBool::new(false);
49
50/// How long a busy runner waits between heartbeats.
51const HEARTBEAT: Duration = Duration::from_secs(10);
52/// How long an idle poll waits for work.
53const IDLE_WAIT_MS: u64 = 20_000;
54/// How often an idle runner checks for a new version.
55const UPDATE_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
56
57const COMMANDS: &[&str] = &["register", "register-and-run", "run", "service", "remove", "update", "version", "--version", "help", "--help", "-h"];
58
59/// Whether `args` (without the program) are a command of this one, rather
60/// than the harness's `MODE`.
61pub fn is_command(args: &[String]) -> bool {
62 args.first().is_some_and(|first| COMMANDS.contains(&first.as_str()))
63}
64
65pub fn log(message: &str) {
66 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
67 println!("{} {message}", g1t_now(now));
68}
69
70/// RFC 3339 for the log, without pulling in a date library.
71fn g1t_now(ms: u64) -> String {
72 let seconds = ms / 1000;
73 let (days, rest) = (seconds / 86_400, seconds % 86_400);
74 let z = days as i64 + 719_468;
75 let era = z.div_euclid(146_097);
76 let doe = z.rem_euclid(146_097);
77 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
78 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
79 let mp = (5 * doy + 2) / 153;
80 let day = doy - (153 * mp + 2) / 5 + 1;
81 let month = if mp < 10 { mp + 3 } else { mp - 9 };
82 let year = yoe + era * 400 + i64::from(month <= 2);
83 format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rest / 3600, rest % 3600 / 60, rest % 60)
84}
85
86/// `--name value` and `--flag` options.
87struct Options {
88 values: Vec<(String, String)>,
89 flags: Vec<String>,
90 rest: Vec<String>,
91}
92
93impl Options {
94 fn parse(args: &[String], takes_value: &[&str]) -> Result<Options> {
95 let mut options = Options { values: Vec::new(), flags: Vec::new(), rest: Vec::new() };
96 let mut i = 0;
97 while i < args.len() {
98 let arg = &args[i];
99 if let Some(name) = arg.strip_prefix("--") {
100 if let Some((name, value)) = name.split_once('=') {
101 options.values.push((name.to_owned(), value.to_owned()));
102 } else if takes_value.contains(&name) {
103 let Some(value) = args.get(i + 1) else { bail!("--{name} needs a value") };
104 options.values.push((name.to_owned(), value.clone()));
105 i += 1;
106 } else {
107 options.flags.push(name.to_owned());
108 }
109 } else {
110 options.rest.push(arg.clone());
111 }
112 i += 1;
113 }
114 Ok(options)
115 }
116
117 fn value(&self, name: &str) -> Option<&str> {
118 self.values.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
119 }
120
121 fn flag(&self, name: &str) -> bool {
122 self.flags.iter().any(|f| f == name)
123 }
124
125 /// For `register-and-run` in a container: each option may come from
126 /// `G1T_RUNNER_<NAME>` instead (`G1T_RUNNER_TOKEN`, `G1T_RUNNER_LABELS`,
127 /// `G1T_RUNNER_EPHEMERAL=1`), so a Kubernetes secret can hold the token.
128 fn with_environment(mut self) -> Options {
129 let env_name = |name: &str| format!("G1T_RUNNER_{}", name.to_ascii_uppercase().replace('-', "_"));
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.130 for name in VALUE_OPTIONS {
Fast pages, required checks on the branch, self-hosted runners, honest incidents131 if self.value(name).is_none()
132 && let Ok(value) = std::env::var(env_name(name))
133 && !value.is_empty()
134 {
135 self.values.push((name.to_owned(), value));
136 }
137 }
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.138 for name in ["ephemeral", "no-docker", "replace", "no-auto-update", "no-job-docker"] {
Fast pages, required checks on the branch, self-hosted runners, honest incidents139 if !self.flag(name) && std::env::var(env_name(name)).is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes")) {
140 self.flags.push(name.to_owned());
141 }
142 }
143 self
144 }
145}
146
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.147/// The options that take a value.
148const VALUE_OPTIONS: [&str; 14] =
149 ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "agent-image", "harness", "memory", "cpus", "pids-limit", "job-docker-image"];
150
Fast pages, required checks on the branch, self-hosted runners, honest incidents151const HELP: &str = "g1t-runner: a self-hosted runner for g1t.
152
153 g1t-runner register --url https://g1t.sh --token <registration token>
154 [--name NAME] [--labels a,b] [--group GROUP] [--ephemeral]
155 [--work-dir DIR] [--no-docker] [--image IMAGE] [--agent-image IMAGE]
156 [--api URL] [--harness PATH] [--replace] [--no-auto-update]
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.157 [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
158 [--job-docker-image IMAGE]
Fast pages, required checks on the branch, self-hosted runners, honest incidents159 g1t-runner run [--once] poll for work and run it
160 g1t-runner register-and-run ... both, for containers; options may be
161 G1T_RUNNER_URL, _TOKEN, _LABELS, _EPHEMERAL…
162 g1t-runner service install|start|stop|status|uninstall
163 g1t-runner remove unregister this runner
164 g1t-runner update update to the newest release
165 g1t-runner version
166
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.167In a workflow step (the g1t/deploy and g1t/detect actions use these):
168 g1t-runner deploy build this checkout and put it on g1t.page
169 g1t-runner detect [--root-dir DIR] what that build would find
170
Fast pages, required checks on the branch, self-hosted runners, honest incidents171Every command takes --dir DIR (default ~/.g1t-runner, or G1T_RUNNER_DIR).
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.172Run it as a container under Sysbox so neither it nor its jobs reach the
173machine: docker run --runtime=sysbox-runc … g1t.sh/flagon-io/g1t-runner.
174--no-docker (host mode) is deprecated.
Fast pages, required checks on the branch, self-hosted runners, honest incidents175Make a registration token under Settings, Runners on g1t.sh.
176Docs: https://docs.g1t.sh/guides/self-hosted-runners/";
177
178/// Runs a command. Returns the process's exit code.
179pub fn main(args: Vec<String>) -> 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.180 let mut takes = VALUE_OPTIONS.to_vec();
181 takes.push("dir");
Fast pages, required checks on the branch, self-hosted runners, honest incidents182 let options = match Options::parse(&args[1..], &takes) {
183 Ok(options) => options,
184 Err(error) => {
185 eprintln!("g1t-runner: {error:#}");
186 return 2;
187 }
188 };
189 let folder = config::folder(options.value("dir"));
190 let outcome = match args[0].as_str() {
191 "register" => register(&options, &folder),
192 // A container's one command: register unless it already is, then run.
193 "register-and-run" => {
194 let options = options.with_environment();
195 let ready = if config::load(&folder).is_ok() { Ok(()) } else { register(&options, &folder) };
196 ready.and_then(|()| run(&folder, options.flag("once")))
197 }
198 "run" => run(&folder, options.flag("once")),
199 "service" => match options.rest.first().map(String::as_str) {
200 Some(action) => config::load(&folder).and_then(|config| service::main(action, &config, &folder)),
201 None => Err(anyhow::anyhow!("service takes install, uninstall, start, stop or status")),
202 },
203 "remove" => remove(&folder),
204 "update" => config::load(&folder).and_then(|config| match update::update(&config)? {
205 Some(version) => {
206 log(&format!("Updated to {version}. Restart the runner (or its service) to use it."));
207 Ok(())
208 }
209 None => {
210 log(&format!("{VERSION} is the newest release."));
211 Ok(())
212 }
213 }),
214 "version" | "--version" => {
215 println!("g1t-runner {VERSION} ({})", update::platform());
216 Ok(())
217 }
218 _ => {
219 println!("{HELP}");
220 Ok(())
221 }
222 };
223 match outcome {
224 Ok(()) => 0,
225 Err(error) => {
226 eprintln!("g1t-runner: {error:#}");
227 1
228 }
229 }
230}
231
232fn hostname() -> String {
233 for name in ["COMPUTERNAME", "HOSTNAME"] {
234 if let Ok(value) = std::env::var(name).map(|v| v.trim().to_owned())
235 && !value.is_empty()
236 {
237 return value;
238 }
239 }
240 std::process::Command::new("hostname")
241 .output()
242 .ok()
243 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
244 .filter(|name| !name.is_empty())
245 .unwrap_or_else(|| "runner".to_owned())
246}
247
248/// A runner's name from a machine's: what g1t accepts.
249fn runner_name(given: &str) -> String {
250 let name: String = given
251 .chars()
252 .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
253 .take(64)
254 .collect();
255 if name.is_empty() { "runner".into() } else { name }
256}
257
258fn register(options: &Options, folder: &Path) -> Result<()> {
259 let Some(url) = options.value("url") else { bail!("--url is needed: the g1t you register with, such as https://g1t.sh") };
260 let Some(token) = options.value("token") else { bail!("--token is needed: make a registration token under Settings, Runners") };
261 let url = url.trim_end_matches('/').to_owned();
262 let api = options.value("api").map(|a| a.trim_end_matches('/').to_owned()).unwrap_or_else(|| config::api_for(&url));
263 let name = runner_name(options.value("name").map(str::to_owned).unwrap_or_else(hostname).as_str());
264 let labels: Vec<String> = options
265 .value("labels")
266 .unwrap_or_default()
267 .split(',')
268 .map(|l| l.trim().to_owned())
269 .filter(|l| !l.is_empty())
270 .collect();
271 let docker = !options.flag("no-docker");
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.272 let isolation = isolation(docker)?;
273 if isolation == sandbox::Isolation::Host {
274 log(sandbox::HOST_MODE_AT_START);
275 } else if !sandbox::nested_requested() && !exec::docker_ready() {
276 log("Docker does not answer here. Jobs will fail until it does.");
Fast pages, required checks on the branch, self-hosted runners, honest incidents277 }
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.278 let pids_limit = match options.value("pids-limit") {
279 Some(text) => Some(text.parse::<u32>().ok().filter(|n| *n > 0).with_context(|| format!("--pids-limit takes a number of processes, not {text}"))?),
280 None => None,
281 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents282 let existing = config::load(folder).ok();
283 if existing.is_some() && !options.flag("replace") {
284 bail!("{} already holds a registered runner: run `g1t-runner remove` first, or register with --replace or another --dir", folder.display());
285 }
286 // The OS jobs run on: in Docker that is Linux, whatever the machine
287 // (Docker Desktop runs Linux containers on macOS and Windows).
288 let os = match std::env::consts::OS {
289 _ if docker => "linux",
290 "macos" => "macos",
291 "windows" => "windows",
292 _ => "linux",
293 };
294 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
295 let registered = Api::register(
296 &api,
297 &json!({
298 "token": token,
299 "name": name,
300 "labels": labels,
301 "os": os,
302 "arch": arch,
303 "version": VERSION,
304 "ephemeral": options.flag("ephemeral"),
305 "group": options.value("group"),
306 "replace": options.flag("replace"),
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.307 "isolation": isolation.as_str(),
Fast pages, required checks on the branch, self-hosted runners, honest incidents308 }),
309 )?;
310 let work_dir = options.value("work-dir").map(PathBuf::from).unwrap_or_else(|| folder.join("work"));
311 let config = Config {
312 url,
313 api,
314 runner: registered.runner.id.clone(),
315 name: registered.runner.name.clone(),
316 credential: registered.credential,
317 workspace: registered.runner.workspace.clone(),
318 repo: registered.runner.repo.clone(),
319 group: registered.runner.group.clone(),
320 labels: registered.runner.labels.clone(),
321 ephemeral: options.flag("ephemeral"),
322 work_dir,
323 docker,
324 image: options.value("image").map(str::to_owned),
325 agent_image: options.value("agent-image").map(str::to_owned),
326 harness: options.value("harness").map(PathBuf::from),
327 auto_update: !options.flag("no-auto-update"),
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.328 memory: options.value("memory").map(str::to_owned),
329 cpus: options.value("cpus").map(str::to_owned),
330 pids_limit,
331 job_docker: !options.flag("no-job-docker"),
332 job_docker_image: options.value("job-docker-image").map(str::to_owned),
Fast pages, required checks on the branch, self-hosted runners, honest incidents333 };
334 config::save(folder, &config)?;
335 let place = match &config.repo {
336 Some(repo) => format!("for {repo}"),
337 None => format!("in {}{}", config.workspace, config.group.as_ref().map(|g| format!(", group {g}")).unwrap_or_default()),
338 };
339 log(&format!("Registered {} {place}, with labels {}.", config.name, config.labels.join(", ")));
340 log("Start it with `g1t-runner run`, or `g1t-runner service install` to keep it running.");
341 Ok(())
342}
343
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.344/// How this runner keeps work from its machine; refuses a nested runner
345/// that can reach the machine (sandbox.rs).
346fn isolation(docker: bool) -> Result<sandbox::Isolation> {
347 use sandbox::{Isolation, SandboxDriver};
348 if !docker {
349 // What is coming for jobs native to this OS.
350 if let Some(driver) = sandbox::native_driver()
351 && let Err(why) = driver.preflight()
352 {
353 log(&format!("Sandboxed {} jobs, in {}: {why}.", std::env::consts::OS, driver.name()));
354 }
355 return Ok(Isolation::Host);
356 }
357 if !sandbox::nested_requested() {
358 return sandbox::SharedDocker.preflight();
359 }
360 if !cfg!(target_os = "linux") {
361 bail!("{}=nested is for the runner's Linux image", sandbox::SANDBOX_ENV);
362 }
363 let isolation = sandbox::NestedDocker { allow_unsafe: sandbox::unsafe_allowed() }.preflight()?;
364 if isolation != Isolation::NestedContainer {
365 for problem in sandbox::Findings::read().problems() {
366 log(&format!("UNSAFE: {problem}"));
367 }
368 log(&format!("UNSAFE: {}=1 is set, so the runner starts anyway, and its jobs can reach this machine.", sandbox::UNSAFE_ENV));
369 }
370 Ok(isolation)
371}
372
373/// A nested runner's own Docker Engine, which it uses whatever else it
374/// can see (with the override, it starts despite that).
375fn prepare(docker: bool) -> Result<()> {
376 if !docker || !sandbox::nested_requested() {
377 return Ok(());
378 }
379 #[cfg(unix)]
380 {
381 let version = engine::start()?;
382 log(&format!("Started this runner's own Docker Engine {version}, inside its container."));
383 }
384 Ok(())
385}
386
Fast pages, required checks on the branch, self-hosted runners, honest incidents387fn remove(folder: &Path) -> Result<()> {
388 let config = config::load(folder)?;
389 let api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
390 match api.remove() {
391 Ok(()) | Err(Failure::Refused(401, _)) | Err(Failure::Refused(404, _)) => {}
392 Err(failure) => bail!("{failure}; the runner is still registered"),
393 }
394 config::forget(folder);
395 log(&format!("Removed {}.", config.name));
396 Ok(())
397}
398
399/// The loop, for a Windows service: its exit code.
400#[cfg_attr(not(windows), allow(dead_code))]
401pub fn serve_from_service() -> i32 {
402 let folder = config::folder(None);
403 let folder = std::env::args()
404 .collect::<Vec<_>>()
405 .windows(2)
406 .find(|pair| pair[0] == "--dir")
407 .map(|pair| PathBuf::from(&pair[1]))
408 .unwrap_or(folder);
409 match run(&folder, false) {
410 Ok(()) => 0,
411 Err(error) => {
412 eprintln!("g1t-runner: {error:#}");
413 1
414 }
415 }
416}
417
418/// Sleeps up to `duration`, waking early when `stop` says so.
419fn nap(duration: Duration, mut stop: impl FnMut() -> bool) {
420 let until = Instant::now() + duration;
421 while Instant::now() < until {
422 if STOP.load(Ordering::SeqCst) || stop() {
423 return;
424 }
425 std::thread::sleep(Duration::from_millis(250));
426 }
427}
428
429fn run(folder: &Path, once: bool) -> Result<()> {
430 let mut config = config::load(folder)?;
431 let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
432 std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?;
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.433 let isolation = isolation(config.docker)?;
434 prepare(config.docker)?;
435 if isolation != sandbox::Isolation::Host {
436 exec::sweep(&config);
437 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents438 log(&format!(
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.439 "g1t-runner {VERSION}: {} ({}) is listening for work from {}. Isolation: {}.",
Fast pages, required checks on the branch, self-hosted runners, honest incidents440 config.name,
441 config.labels.join(", "),
442 config.url,
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.443 isolation.describe()
Fast pages, required checks on the branch, self-hosted runners, honest incidents444 ));
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.445 if isolation == sandbox::Isolation::Host {
446 log(sandbox::HOST_MODE_AT_START);
447 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents448 let mut current: Option<exec::Running> = None;
449 let mut ran_one = false;
450 let mut backoff = Duration::from_secs(2);
451 let mut last_update_check = Instant::now().checked_sub(UPDATE_EVERY).unwrap_or_else(Instant::now);
452 let mut said_no_key = false;
453 loop {
454 if STOP.load(Ordering::SeqCst) {
455 if let Some(mut running) = current.take() {
456 log(&format!("Stopping: {} is cut short.", running.name));
457 running.stop();
458 let _ = api.finished(&running.id, 1, Some("The self-hosted runner was stopped."));
459 running.clean();
460 }
461 return Ok(());
462 }
463 // What was running has ended: say how.
464 if let Some(running) = current.as_mut()
465 && let Some(code) = running.ended()
466 {
467 let mut running = current.take().expect("checked above");
468 log(&format!("{} {} ended ({code}).", running.kind, running.name));
469 if let Err(failure) = api.finished(&running.id, code, None) {
470 log(&format!("Could not say how it ended: {failure}"));
471 }
472 running.clean();
473 ran_one = true;
474 if config.ephemeral || once {
475 if config.ephemeral {
476 let _ = api.remove();
477 config::forget(folder);
478 log("The ephemeral runner ran its job and removed itself.");
479 }
480 return Ok(());
481 }
482 }
483 // Idle: a newer release, now and then.
484 if current.is_none() && config.auto_update && last_update_check.elapsed() >= UPDATE_EVERY {
485 last_update_check = Instant::now();
486 if update::can_update() {
487 match update::update(&config) {
488 Ok(Some(version)) => {
489 log(&format!("Updated to {version}; starting it."));
490 return restart();
491 }
492 Ok(None) => {}
493 Err(error) => log(&format!("Could not check for a new version: {error:#}")),
494 }
495 } else if !said_no_key {
496 said_no_key = true;
497 log("This build cannot check releases, so it will not update itself.");
498 }
499 }
500 let running: Vec<String> = current.iter().map(|r| r.id.clone()).collect();
501 let wait = if current.is_some() { 0 } else { IDLE_WAIT_MS };
502 let poll = match api.poll(&running, wait) {
503 Ok(poll) => {
504 backoff = Duration::from_secs(2);
505 poll
506 }
507 Err(Failure::Refused(401, message)) => {
508 if let Some(mut running) = current.take() {
509 running.stop();
510 running.clean();
511 }
512 bail!("g1t no longer knows this runner ({message}). Register it again.");
513 }
514 Err(failure) => {
515 log(&format!("{failure}; trying again in {}s", backoff.as_secs()));
516 nap(backoff, || false);
517 backoff = (backoff * 2).min(Duration::from_secs(60));
518 continue;
519 }
520 };
521 if let Some(fresh) = poll.credential {
522 config.credential = fresh.clone();
523 api.credential = fresh;
524 config::save(folder, &config)?;
525 }
526 if poll.removed {
527 config::forget(folder);
528 log("This runner was removed from g1t; stopping.");
529 return Ok(());
530 }
531 if let Some(running) = current.as_mut()
532 && poll.cancel.contains(&running.id)
533 {
534 log(&format!("{} was cancelled; stopping it.", running.name));
535 running.stop();
536 }
537 if let Some(running) = current.as_mut()
538 && running.overdue()
539 {
540 log(&format!("{} ran past its time limit; stopping it.", running.name));
541 running.stop();
542 }
543 if let Some(work) = poll.assignment {
544 log(&format!("Took {} {} ({}).", work.kind, work.name, work.repo));
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.545 match exec::start(&config, isolation, folder, &work) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents546 Ok(running) => current = Some(running),
547 Err(error) => {
548 log(&format!("Could not start it: {error:#}"));
549 let _ = api.finished(&work.id, 1, Some(&format!("The self-hosted runner {} could not start it: {error:#}", config.name)));
550 if config.ephemeral || once {
551 return Ok(());
552 }
553 }
554 }
555 continue;
556 }
557 if once && ran_one {
558 return Ok(());
559 }
560 // Busy: a heartbeat every few seconds until it ends.
561 if current.is_some() {
562 let mut check = || current.as_mut().is_some_and(|r| r.ended().is_some());
563 nap(HEARTBEAT, &mut check);
564 }
565 }
566}
567
568/// Runs the updated program in this one's place, with the same arguments.
569fn restart() -> Result<()> {
570 let me = std::env::current_exe()?;
571 let status = std::process::Command::new(me).args(std::env::args().skip(1)).status()?;
572 std::process::exit(status.code().unwrap_or(1));
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 fn args(items: &[&str]) -> Vec<String> {
580 items.iter().map(|s| s.to_string()).collect()
581 }
582
583 #[test]
584 fn commands_are_told_apart_from_the_harness() {
585 assert!(is_command(&args(&["register", "--url", "x"])));
586 assert!(is_command(&args(&["run"])));
587 assert!(!is_command(&args(&[])));
588 assert!(!is_command(&args(&["something"])));
589 }
590
591 #[test]
592 fn options_read_both_ways() {
593 let options = Options::parse(&args(&["--url", "https://g1t.sh", "--labels=gpu,cuda", "--ephemeral", "install"]), &["url", "labels"]).unwrap();
594 assert_eq!(options.value("url"), Some("https://g1t.sh"));
595 assert_eq!(options.value("labels"), Some("gpu,cuda"));
596 assert!(options.flag("ephemeral"));
597 assert_eq!(options.rest, vec!["install".to_owned()]);
598 assert!(Options::parse(&args(&["--url"]), &["url"]).is_err());
599 }
600
601 #[test]
602 fn a_machines_name_becomes_a_runners() {
603 assert_eq!(runner_name("Build Box #1"), "Build-Box--1");
604 assert_eq!(runner_name(""), "runner");
605 assert_eq!(runner_name(&"x".repeat(80)).len(), 64);
606 }
607
608 #[test]
609 fn the_log_says_when() {
610 assert_eq!(g1t_now(1_790_918_179_123), "2026-10-02T05:16:19Z");
611 }
612}

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