Skip to content

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

g1t.sh
550 linesCodeBlameRaw
1//! Telling g1t how a job is going: its steps, its log in batches, its
2//! annotations, and how it ended. Every report carries the job's token.
3
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use g1t_actions::mask;
9use serde_json::{Value, json};
10
11/// How long log lines wait before they are sent: about a second, so a
12/// running step's page follows it closely, at one write to g1t a second.
13const FLUSH_EVERY: Duration = Duration::from_millis(1000);
14/// How much log is sent at once.
15const FLUSH_BYTES: usize = 64 * 1024;
16/// The most one step may add to the job's summary, as on GitHub.
17pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
18/// How long the runner goes without a report before it asks whether the
19/// job was cancelled.
20const PING_EVERY: Duration = Duration::from_secs(10);
21
22/// Whether the job was cancelled, as g1t's answers to its reports say.
23struct Cancel {
24 /// g1t said so.
25 said: AtomicBool,
26 /// The job has taken it in: the step it was on was stopped, and the
27 /// cleanup steps that follow are not stopped for it again.
28 taken: AtomicBool,
29}
30
31impl Cancel {
32 const fn new() -> Cancel {
33 Cancel { said: AtomicBool::new(false), taken: AtomicBool::new(false) }
34 }
35
36 /// Reads an answer to a report: `cancelled` when the run was cancelled.
37 fn hear(&self, answer: &Value) {
38 if answer["cancelled"].as_bool() == Some(true) {
39 self.said.store(true, Ordering::Relaxed);
40 }
41 }
42
43 fn cancelled(&self) -> bool {
44 self.said.load(Ordering::Relaxed)
45 }
46
47 fn interrupt(&self) -> bool {
48 self.cancelled() && !self.taken.load(Ordering::Relaxed)
49 }
50
51 fn take(&self) {
52 self.taken.store(true, Ordering::Relaxed);
53 }
54}
55
56/// This job's (one runs per process).
57static CANCEL: Cancel = Cancel::new();
58
59/// Whether g1t said the job was cancelled.
60pub(crate) fn cancelled() -> bool {
61 CANCEL.cancelled()
62}
63
64/// Whether a running step should be stopped: cancelled, and not yet taken in.
65pub(crate) fn interrupt() -> bool {
66 CANCEL.interrupt()
67}
68
69/// The job has seen the cancellation: what runs from here is its cleanup.
70pub(crate) fn take_cancel() {
71 CANCEL.take();
72}
73
74pub(crate) struct Api {
75 pub(crate) base: String,
76 pub(crate) job: String,
77 pub(crate) token: String,
78}
79
80impl Api {
81 pub(crate) fn spec(&self) -> Result<Value> {
82 let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job))
83 .timeout(Duration::from_secs(60))
84 .send_json(json!({ "token": self.token }))?;
85 Ok(response.into_json()?)
86 }
87
88 /// Where to fetch another repository's action from: `{"source": "g1t",
89 /// "url", "ref", "token"}`, or `{"source": "github", "sha", "archive",
90 /// "note"}`: the commit GitHub's ref names, and whether g1t keeps its
91 /// archive (`action_archive`). Err holds why g1t
92 /// refused (`true`: the repository is private and may not be used
93 /// here), or that it could not be asked (`false`).
94 pub(crate) fn action(&self, repository: &str, git_ref: &str) -> std::result::Result<Value, (bool, String)> {
95 // The first job to use an action at a commit waits while g1t
96 // fetches and keeps its archive.
97 let sent = ureq::post(&format!("{}/actions/jobs/{}/action", self.base, self.job))
98 .timeout(Duration::from_secs(120))
99 .send_json(json!({ "token": self.token, "report": { "repository": repository, "ref": git_ref } }));
100 match sent {
101 Ok(response) => response.into_json().map_err(|error| (false, error.to_string())),
102 Err(ureq::Error::Status(code, response)) => {
103 let body: Value = response.into_json().unwrap_or(Value::Null);
104 let message = body["error"]["message"].as_str().unwrap_or("g1t did not answer.").to_owned();
105 Err((code == 403, message))
106 }
107 Err(error) => Err((false, error.to_string())),
108 }
109 }
110
111 /// Downloads the archive g1t keeps of `repository` at `sha` to `to`.
112 pub(crate) fn action_archive(&self, repository: &str, sha: &str, to: &std::path::Path) -> std::result::Result<u64, String> {
113 let response = ureq::post(&format!("{}/actions/jobs/{}/archive", self.base, self.job))
114 .timeout(Duration::from_secs(300))
115 .send_json(json!({ "token": self.token, "report": { "repository": repository, "sha": sha } }))
116 .map_err(|error| match error {
117 ureq::Error::Status(code, response) => {
118 let body: Value = response.into_json().unwrap_or(Value::Null);
119 body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {code}"), str::to_owned)
120 }
121 other => other.to_string(),
122 })?;
123 let mut file = std::fs::File::create(to).map_err(|error| error.to_string())?;
124 std::io::copy(&mut response.into_reader(), &mut file).map_err(|error| error.to_string())
125 }
126
127 /// An API with no address, which sends nothing: what the tests use, so
128 /// a step's run is not held up by reports that go nowhere (each one
129 /// tried three times, with waits between, over a second and a half).
130 #[cfg(test)]
131 pub(crate) fn offline() -> Api {
132 Api { base: String::new(), job: "job_1".into(), token: "t".into() }
133 }
134
135 pub(crate) fn report(&self, report: Value) {
136 // No address: nowhere to send it (Api::offline).
137 if self.base.is_empty() {
138 return;
139 }
140 // A report that cannot be sent is tried a few times, then dropped:
141 // the job goes on, and g1t notices a silent job by itself.
142 for attempt in 0..3 {
143 let sent = ureq::post(&format!("{}/actions/jobs/{}", self.base, self.job))
144 .timeout(Duration::from_secs(30))
145 .send_json(json!({ "token": self.token, "report": report }));
146 match sent {
147 Ok(response) => {
148 if let Ok(answer) = response.into_json::<Value>() {
149 CANCEL.hear(&answer);
150 }
151 return;
152 }
153 // Refused: the job was cancelled or finished; nothing to retry.
154 Err(ureq::Error::Status(code, _)) if (400..500).contains(&code) => return,
155 Err(_) => std::thread::sleep(Duration::from_millis(500 * (attempt + 1))),
156 }
157 }
158 }
159}
160
161/// The job's log, masked, sent in batches. `masks` holds every form of
162/// every secret (`g1t_actions::mask`), longest first.
163pub(crate) struct Log {
164 pub(crate) api: Api,
165 pub(crate) masks: Vec<String>,
166 step: u32,
167 buffer: String,
168 last: Instant,
169 /// When anything was last sent, for the ping.
170 sent: Instant,
171 /// The problem matchers steps added, which last the whole job.
172 pub(crate) matchers: super::matchers::Matchers,
173 /// The number of the last batch sent, 1 for the job's first: each
174 /// batch carries its own, and keeps it when it is sent again, so g1t
175 /// keeps a batch it already has once.
176 chunk: u64,
177 /// Everything sent so far, so a test reads the whole log however its
178 /// batches fell (`buffered`).
179 #[cfg(test)]
180 flushed: String,
181}
182
183impl Log {
184 pub(crate) fn new(api: Api, mut masks: Vec<String>) -> Log {
185 masks.retain(|mask| !mask.is_empty());
186 masks.sort_by_key(|mask| std::cmp::Reverse(mask.len()));
187 Log {
188 api,
189 masks,
190 step: 0,
191 buffer: String::new(),
192 last: Instant::now(),
193 sent: Instant::now(),
194 matchers: super::matchers::Matchers::default(),
195 chunk: 0,
196 #[cfg(test)]
197 flushed: String::new(),
198 }
199 }
200
201 /// The log so far, what was sent and what waits to be, without the
202 /// time each line is stamped with.
203 #[cfg(test)]
204 pub(crate) fn buffered(&self) -> String {
205 unstamped(&format!("{}{}", self.flushed, self.buffer))
206 }
207
208 /// Starts writing to step `number` (0 for the job's setup).
209 pub(crate) fn step(&mut self, number: u32) {
210 self.flush();
211 self.step = number;
212 }
213
214 pub(crate) fn mask(&self, text: &str) -> String {
215 mask::apply(text, &self.masks)
216 }
217
218 /// `::add-mask::`: masks `value` from here on, in every form it can
219 /// take (each line, base64, JSON-escaped), as a secret is.
220 pub(crate) fn add_mask(&mut self, value: &str) {
221 let mut added = false;
222 for variant in mask::variants(value) {
223 if !self.masks.contains(&variant) {
224 self.masks.push(variant);
225 added = true;
226 }
227 }
228 if added {
229 self.masks.sort_by_key(|mask| std::cmp::Reverse(mask.len()));
230 }
231 }
232
233 /// Adds a line to the log, masked, each of its lines stamped with the
234 /// time, as GitHub's runner writes its logs
235 /// (`2026-10-12T09:41:07.1234567Z text`).
236 pub(crate) fn line(&mut self, text: &str) {
237 let masked = self.mask(text);
238 let at = stamp(SystemTime::now());
239 for line in masked.split('\n') {
240 self.buffer.push_str(&at);
241 self.buffer.push(' ');
242 self.buffer.push_str(line);
243 self.buffer.push('\n');
244 }
245 if self.buffer.len() >= FLUSH_BYTES || self.last.elapsed() >= FLUSH_EVERY {
246 self.flush();
247 }
248 }
249
250 /// Sends what is waiting if it has waited long enough, and asks after
251 /// the job when nothing has been sent for a while, so a cancellation
252 /// reaches a step that prints nothing.
253 pub(crate) fn tick(&mut self) {
254 if !self.buffer.is_empty() && self.last.elapsed() >= FLUSH_EVERY {
255 self.flush();
256 } else if self.sent.elapsed() >= PING_EVERY {
257 self.sent = Instant::now();
258 self.api.report(json!({ "kind": "ping" }));
259 }
260 }
261
262 pub(crate) fn flush(&mut self) {
263 self.last = Instant::now();
264 if self.buffer.is_empty() {
265 return;
266 }
267 self.sent = Instant::now();
268 let text = std::mem::take(&mut self.buffer);
269 #[cfg(test)]
270 self.flushed.push_str(&text);
271 let report = self.log_report(text);
272 self.api.report(report);
273 }
274
275 /// The next batch of the log, numbered.
276 fn log_report(&mut self, text: String) -> Value {
277 self.chunk += 1;
278 json!({ "kind": "log", "step": self.step, "chunk": self.chunk, "text": text })
279 }
280
281 pub(crate) fn steps(&self, names: &[String]) {
282 self.api.report(json!({ "kind": "steps", "steps": names }));
283 }
284
285 pub(crate) fn step_state(&mut self, number: u32, name: &str, status: &str, conclusion: Option<&str>) {
286 self.flush();
287 let name = self.mask(name);
288 self.api.report(json!({ "kind": "step", "number": number, "name": name, "status": status, "conclusion": conclusion }));
289 }
290
291 /// What a step wrote to `$GITHUB_STEP_SUMMARY`, masked, for the run's
292 /// page. More than 1 MiB is refused, as on GitHub, with an error in the
293 /// log.
294 pub(crate) fn summary(&mut self, markdown: &str) {
295 if markdown.trim().is_empty() {
296 return;
297 }
298 if markdown.len() > MAX_SUMMARY_BYTES {
299 self.line(&format!(
300 "##[error]$GITHUB_STEP_SUMMARY upload aborted: a step's summary may be up to 1024k, and this one is {}k.",
301 markdown.len().div_ceil(1024)
302 ));
303 return;
304 }
305 let markdown = self.mask(markdown);
306 self.flush();
307 self.api.report(json!({ "kind": "summary", "step": self.step, "markdown": markdown }));
308 }
309
310 /// An annotation, from `::error::` and its kin or a problem matcher:
311 /// its `file`, `line`, `col`, `endLine` and `endColumn` as GitHub's
312 /// commands spell them, sent as `file`, `line`, `column`, `end_line`
313 /// and `end_column`.
314 pub(crate) fn annotation(&mut self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) {
315 let report = self.annotation_report(level, message, properties);
316 self.api.report(report);
317 }
318
319 fn annotation_report(&self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) -> Value {
320 let message = self.mask(message);
321 let title = properties.get("title").and_then(Value::as_str).map(|title| self.mask(title));
322 let number = |keys: &[&str]| keys.iter().find_map(|key| properties.get(*key).and_then(Value::as_str)).and_then(|n| n.trim().parse::<u32>().ok());
323 json!({
324 "kind": "annotation",
325 "level": level,
326 "message": message,
327 "title": title,
328 "file": properties.get("file"),
329 "line": number(&["line"]),
330 "column": number(&["col"]),
331 "end_line": number(&["endLine"]),
332 "end_column": number(&["endColumn"]),
333 })
334 }
335
336 pub(crate) fn done(&mut self, conclusion: &str, outputs: &serde_json::Map<String, Value>, reason: Option<&str>) {
337 let outputs = self.withhold_secrets(outputs);
338 self.flush();
339 self.api.report(json!({ "kind": "done", "conclusion": conclusion, "outputs": outputs, "reason": reason }));
340 }
341
342 /// The job's outputs without any that hold a secret, as on GitHub: an
343 /// output goes to other jobs and to the run's page, where no mask
344 /// reaches. Each one left out is warned of in the log.
345 pub(crate) fn withhold_secrets(&mut self, outputs: &serde_json::Map<String, Value>) -> serde_json::Map<String, Value> {
346 let mut kept = serde_json::Map::new();
347 for (name, value) in outputs {
348 let text = match value {
349 Value::String(text) => text.clone(),
350 other => other.to_string(),
351 };
352 if mask::reveals(&text, &self.masks) {
353 self.line(&format!("##[warning]The output `{name}` was left out: it holds a secret."));
354 continue;
355 }
356 kept.insert(name.clone(), value.clone());
357 }
358 kept
359 }
360}
361
362/// A time as GitHub's logs stamp each line: RFC 3339 in UTC, to a tenth
363/// of a microsecond (`2026-10-12T09:41:07.1234567Z`).
364pub(crate) fn stamp(at: SystemTime) -> String {
365 let since = at.duration_since(UNIX_EPOCH).unwrap_or_default();
366 let seconds = since.as_secs();
367 let (days, rest) = (seconds / 86_400, seconds % 86_400);
368 let (year, month, day) = civil(days as i64);
369 format!(
370 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:07}Z",
371 rest / 3600,
372 rest % 3600 / 60,
373 rest % 60,
374 since.subsec_nanos() / 100
375 )
376}
377
378/// The calendar date `days` after 1970-01-01 (Howard Hinnant's
379/// `civil_from_days`).
380fn civil(days: i64) -> (i64, u32, u32) {
381 let z = days + 719_468;
382 let era = z.div_euclid(146_097);
383 let doe = z.rem_euclid(146_097);
384 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
385 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
386 let mp = (5 * doy + 2) / 153;
387 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
388 let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
389 let year = yoe + era * 400 + i64::from(month <= 2);
390 (year, month, day)
391}
392
393/// A log without the time stamped at the start of each line.
394#[cfg(test)]
395pub(crate) fn unstamped(text: &str) -> String {
396 text.split_inclusive('\n')
397 .map(|line| match line.split_once(' ') {
398 Some((at, rest)) if at.len() == 28 && at.ends_with('Z') && at.as_bytes()[10] == b'T' => rest,
399 _ => line,
400 })
401 .collect()
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn every_line_is_stamped_with_the_time_as_github_stamps_it() {
410 assert_eq!(stamp(UNIX_EPOCH), "1970-01-01T00:00:00.0000000Z");
411 assert_eq!(stamp(UNIX_EPOCH + Duration::new(1_791_797_067, 123_456_789)), "2026-10-12T09:24:27.1234567Z");
412 // A leap day, and the last day of February in a century that has none.
413 assert_eq!(stamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.0000000Z");
414 assert_eq!(stamp(UNIX_EPOCH + Duration::from_secs(4_107_542_399)), "2100-02-28T23:59:59.0000000Z");
415 let mut log = log(&["s3cr3t"]);
416 log.line("one s3cr3t\ntwo");
417 let lines: Vec<&str> = log.buffer.lines().collect();
418 assert_eq!(lines.len(), 2, "each line of a message is stamped");
419 for (line, text) in lines.iter().zip(["one ***", "two"]) {
420 let (at, rest) = line.split_once(' ').unwrap();
421 assert_eq!((at.len(), &at[10..11], &at[27..]), (28, "T", "Z"), "{line}");
422 assert_eq!(rest, text);
423 }
424 assert_eq!(log.buffered(), "one ***\ntwo\n");
425 }
426
427 fn log(secrets: &[&str]) -> Log {
428 Log::new(Api::offline(), mask::all_variants(secrets.iter().copied()))
429 }
430
431 /// A server that answers the first `fail` requests with a 500 and the
432 /// rest with `{}`, and hands back each request's body.
433 fn flaky_server(fail: usize, requests: usize) -> (String, std::thread::JoinHandle<Vec<Value>>) {
434 use std::io::{BufRead, BufReader, Read, Write};
435 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
436 let base = format!("http://{}", listener.local_addr().unwrap());
437 let serving = std::thread::spawn(move || {
438 let mut bodies = Vec::new();
439 for answered in 0..requests {
440 let (stream, _) = listener.accept().unwrap();
441 let mut reader = BufReader::new(stream);
442 let mut length = 0;
443 loop {
444 let mut header = String::new();
445 reader.read_line(&mut header).unwrap();
446 if header.trim().is_empty() {
447 break;
448 }
449 if let Some((name, value)) = header.split_once(':')
450 && name.eq_ignore_ascii_case("content-length")
451 {
452 length = value.trim().parse().unwrap();
453 }
454 }
455 let mut body = vec![0; length];
456 reader.read_exact(&mut body).unwrap();
457 bodies.push(serde_json::from_slice::<Value>(&body).unwrap());
458 let status = if answered < fail { "500 Internal Server Error" } else { "200 OK" };
459 write!(reader.get_mut(), "HTTP/1.1 {status}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").unwrap();
460 }
461 bodies
462 });
463 (base, serving)
464 }
465
466 #[test]
467 fn each_batch_of_the_log_keeps_its_number_when_sent_again() {
468 let (base, serving) = flaky_server(1, 3);
469 let mut log = Log::new(Api { base, job: "job_1".into(), token: "t".into() }, Vec::new());
470 log.line("one");
471 log.flush();
472 log.line("two");
473 log.flush();
474 log.flush(); // Nothing waits: no batch, and no number used.
475 let bodies = serving.join().unwrap();
476 let sent: Vec<(u64, String)> = bodies
477 .iter()
478 .map(|body| (body["report"]["chunk"].as_u64().unwrap(), unstamped(body["report"]["text"].as_str().unwrap())))
479 .collect();
480 // The first batch failed and was sent again with its number; the
481 // next is the job's second.
482 assert_eq!(sent, [(1, "one\n".into()), (1, "one\n".into()), (2, "two\n".into())]);
483 assert_eq!(bodies[0], bodies[1], "a batch sent again is the same batch");
484 }
485
486 #[test]
487 fn outputs_holding_a_secret_are_left_out() {
488 let mut log = log(&["s3cr3t-token"]);
489 let mut outputs = serde_json::Map::new();
490 outputs.insert("version".into(), json!("1.2.0"));
491 outputs.insert("leak".into(), json!("token=s3cr3t-token"));
492 outputs.insert("encoded".into(), json!("czNjcjN0LXRva2Vu"));
493 let kept = log.withhold_secrets(&outputs);
494 assert_eq!(kept.keys().collect::<Vec<_>>(), ["version"]);
495 assert!(log.buffer.contains("The output `leak` was left out"));
496 assert!(log.buffer.contains("The output `encoded` was left out"));
497 }
498
499 #[test]
500 fn a_cancelled_answer_is_heard_once_and_taken_in() {
501 let cancel = Cancel::new();
502 cancel.hear(&json!({ "ok": true }));
503 assert!(!cancel.cancelled());
504 cancel.hear(&json!({ "ok": true, "cancelled": true }));
505 assert!(cancel.cancelled() && cancel.interrupt());
506 cancel.take();
507 assert!(cancel.cancelled() && !cancel.interrupt(), "cleanup steps are not stopped again");
508 cancel.hear(&json!({ "ok": true, "cancelled": false }));
509 assert!(cancel.cancelled(), "a cancelled job stays cancelled");
510 }
511
512 #[test]
513 fn a_summary_past_its_limit_is_refused_in_the_log() {
514 let mut log = log(&[]);
515 log.summary("
516");
517 assert!(log.buffer.is_empty(), "an empty summary is not sent");
518 log.summary(&"x".repeat(MAX_SUMMARY_BYTES + 1));
519 assert!(log.buffer.contains("$GITHUB_STEP_SUMMARY upload aborted"));
520 assert!(log.buffer.contains("1025k"));
521 }
522
523 #[test]
524 fn annotations_carry_their_columns_and_end() {
525 let log = log(&["hidden"]);
526 let properties: serde_json::Map<String, Value> =
527 serde_json::from_value(json!({ "file": "a.rs", "line": "3", "col": "7", "endLine": "4", "endColumn": "2", "title": "t hidden" })).unwrap();
528 let report = log.annotation_report("error", "bad hidden", &properties);
529 assert_eq!(report["line"], 3);
530 assert_eq!(report["column"], 7);
531 assert_eq!(report["end_line"], 4);
532 assert_eq!(report["end_column"], 2);
533 assert_eq!(report["title"], "t ***");
534 assert_eq!(report["message"], "bad ***");
535 let bare = log.annotation_report("notice", "x", &serde_json::Map::new());
536 assert!(bare["column"].is_null() && bare["file"].is_null());
537 }
538
539 #[test]
540 fn added_masks_cover_every_form() {
541 let mut log = log(&[]);
542 log.add_mask("line one\nline two");
543 assert_eq!(log.mask("first: line one"), "first: ***");
544 assert_eq!(log.mask("then line two"), "then ***");
545 // Longest first: the whole value, not its pieces.
546 log.add_mask("abc");
547 log.add_mask("abcdef");
548 assert_eq!(log.mask("abcdef"), "***");
549 }
550}