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

GitHub Actions on g1t, part two: running workflows1//! 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::time::{Duration, Instant};
5
6use anyhow::Result;
Merge branch 'worktree-agent-a3abfcce648e87dca'7use g1t_actions::mask;
GitHub Actions on g1t, part two: running workflows8use serde_json::{Value, json};
9
10/// How long log lines wait before they are sent.
11const FLUSH_EVERY: Duration = Duration::from_millis(1500);
12/// How much log is sent at once.
13const FLUSH_BYTES: usize = 64 * 1024;
14
15pub(crate) struct Api {
16 pub(crate) base: String,
17 pub(crate) job: String,
18 pub(crate) token: String,
19}
20
21impl Api {
22 pub(crate) fn spec(&self) -> Result<Value> {
23 let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job))
24 .timeout(Duration::from_secs(60))
25 .send_json(json!({ "token": self.token }))?;
26 Ok(response.into_json()?)
27 }
28
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts29 /// Where to fetch another repository's action from: `{"source": "g1t",
30 /// "url", "ref", "token"}` or `{"source": "github"}`. Err holds why g1t
31 /// refused (`true`: the repository is private and may not be used
32 /// here), or that it could not be asked (`false`).
33 pub(crate) fn action(&self, repository: &str, git_ref: &str) -> std::result::Result<Value, (bool, String)> {
34 let sent = ureq::post(&format!("{}/actions/jobs/{}/action", self.base, self.job))
35 .timeout(Duration::from_secs(30))
36 .send_json(json!({ "token": self.token, "report": { "repository": repository, "ref": git_ref } }));
37 match sent {
38 Ok(response) => response.into_json().map_err(|error| (false, error.to_string())),
39 Err(ureq::Error::Status(code, response)) => {
40 let body: Value = response.into_json().unwrap_or(Value::Null);
41 let message = body["error"]["message"].as_str().unwrap_or("g1t did not answer.").to_owned();
42 Err((code == 403, message))
43 }
44 Err(error) => Err((false, error.to_string())),
45 }
46 }
47
GitHub Actions on g1t, part two: running workflows48 pub(crate) fn report(&self, report: Value) {
49 // A report that cannot be sent is tried a few times, then dropped:
50 // the job goes on, and g1t notices a silent job by itself.
51 for attempt in 0..3 {
52 let sent = ureq::post(&format!("{}/actions/jobs/{}", self.base, self.job))
53 .timeout(Duration::from_secs(30))
54 .send_json(json!({ "token": self.token, "report": report }));
55 match sent {
56 Ok(_) => return,
57 // Refused: the job was cancelled or finished; nothing to retry.
58 Err(ureq::Error::Status(code, _)) if (400..500).contains(&code) => return,
59 Err(_) => std::thread::sleep(Duration::from_millis(500 * (attempt + 1))),
60 }
61 }
62 }
63}
64
Merge branch 'worktree-agent-a3abfcce648e87dca'65/// The job's log, masked, sent in batches. `masks` holds every form of
66/// every secret (`g1t_actions::mask`), longest first.
GitHub Actions on g1t, part two: running workflows67pub(crate) struct Log {
68 pub(crate) api: Api,
69 pub(crate) masks: Vec<String>,
70 step: u32,
71 buffer: String,
72 last: Instant,
73}
74
75impl Log {
Merge branch 'worktree-agent-a3abfcce648e87dca'76 pub(crate) fn new(api: Api, mut masks: Vec<String>) -> Log {
77 masks.retain(|mask| !mask.is_empty());
78 masks.sort_by_key(|mask| std::cmp::Reverse(mask.len()));
GitHub Actions on g1t, part two: running workflows79 Log {
80 api,
81 masks,
82 step: 0,
83 buffer: String::new(),
84 last: Instant::now(),
85 }
86 }
87
88 /// Starts writing to step `number` (0 for the job's setup).
89 pub(crate) fn step(&mut self, number: u32) {
90 self.flush();
91 self.step = number;
92 }
93
94 pub(crate) fn mask(&self, text: &str) -> String {
Merge branch 'worktree-agent-a3abfcce648e87dca'95 mask::apply(text, &self.masks)
96 }
97
98 /// `::add-mask::`: masks `value` from here on, in every form it can
99 /// take (each line, base64, JSON-escaped), as a secret is.
100 pub(crate) fn add_mask(&mut self, value: &str) {
101 let mut added = false;
102 for variant in mask::variants(value) {
103 if !self.masks.contains(&variant) {
104 self.masks.push(variant);
105 added = true;
GitHub Actions on g1t, part two: running workflows106 }
107 }
Merge branch 'worktree-agent-a3abfcce648e87dca'108 if added {
109 self.masks.sort_by_key(|mask| std::cmp::Reverse(mask.len()));
110 }
GitHub Actions on g1t, part two: running workflows111 }
112
113 pub(crate) fn line(&mut self, text: &str) {
114 let masked = self.mask(text);
115 self.buffer.push_str(&masked);
116 self.buffer.push('\n');
117 if self.buffer.len() >= FLUSH_BYTES || self.last.elapsed() >= FLUSH_EVERY {
118 self.flush();
119 }
120 }
121
122 /// Sends what is waiting if it has waited long enough.
123 pub(crate) fn tick(&mut self) {
124 if !self.buffer.is_empty() && self.last.elapsed() >= FLUSH_EVERY {
125 self.flush();
126 }
127 }
128
129 pub(crate) fn flush(&mut self) {
130 self.last = Instant::now();
131 if self.buffer.is_empty() {
132 return;
133 }
134 let text = std::mem::take(&mut self.buffer);
135 self.api.report(json!({ "kind": "log", "step": self.step, "text": text }));
136 }
137
138 pub(crate) fn steps(&self, names: &[String]) {
139 self.api.report(json!({ "kind": "steps", "steps": names }));
140 }
141
142 pub(crate) fn step_state(&mut self, number: u32, name: &str, status: &str, conclusion: Option<&str>) {
143 self.flush();
144 let name = self.mask(name);
145 self.api.report(json!({ "kind": "step", "number": number, "name": name, "status": status, "conclusion": conclusion }));
146 }
147
148 pub(crate) fn annotation(&mut self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) {
149 let message = self.mask(message);
Merge branch 'worktree-agent-a3abfcce648e87dca'150 let title = properties.get("title").and_then(Value::as_str).map(|title| self.mask(title));
GitHub Actions on g1t, part two: running workflows151 self.api.report(json!({
152 "kind": "annotation",
153 "level": level,
154 "message": message,
Merge branch 'worktree-agent-a3abfcce648e87dca'155 "title": title,
GitHub Actions on g1t, part two: running workflows156 "file": properties.get("file"),
157 "line": properties.get("line").and_then(|l| l.as_str()).and_then(|l| l.parse::<u32>().ok()),
158 }));
159 }
160
161 pub(crate) fn done(&mut self, conclusion: &str, outputs: &serde_json::Map<String, Value>, reason: Option<&str>) {
Merge branch 'worktree-agent-a3abfcce648e87dca'162 let outputs = self.withhold_secrets(outputs);
GitHub Actions on g1t, part two: running workflows163 self.flush();
164 self.api.report(json!({ "kind": "done", "conclusion": conclusion, "outputs": outputs, "reason": reason }));
165 }
Merge branch 'worktree-agent-a3abfcce648e87dca'166
167 /// The job's outputs without any that hold a secret, as on GitHub: an
168 /// output goes to other jobs and to the run's page, where no mask
169 /// reaches. Each one left out is warned of in the log.
170 pub(crate) fn withhold_secrets(&mut self, outputs: &serde_json::Map<String, Value>) -> serde_json::Map<String, Value> {
171 let mut kept = serde_json::Map::new();
172 for (name, value) in outputs {
173 let text = match value {
174 Value::String(text) => text.clone(),
175 other => other.to_string(),
176 };
177 if mask::reveals(&text, &self.masks) {
178 self.line(&format!("##[warning]The output `{name}` was left out: it holds a secret."));
179 continue;
180 }
181 kept.insert(name.clone(), value.clone());
182 }
183 kept
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn log(secrets: &[&str]) -> Log {
192 let api = Api { base: "http://127.0.0.1:9".into(), job: "job_1".into(), token: "t".into() };
193 Log::new(api, mask::all_variants(secrets.iter().copied()))
194 }
195
196 #[test]
197 fn outputs_holding_a_secret_are_left_out() {
198 let mut log = log(&["s3cr3t-token"]);
199 let mut outputs = serde_json::Map::new();
200 outputs.insert("version".into(), json!("1.2.0"));
201 outputs.insert("leak".into(), json!("token=s3cr3t-token"));
202 outputs.insert("encoded".into(), json!("czNjcjN0LXRva2Vu"));
203 let kept = log.withhold_secrets(&outputs);
204 assert_eq!(kept.keys().collect::<Vec<_>>(), ["version"]);
205 assert!(log.buffer.contains("The output `leak` was left out"));
206 assert!(log.buffer.contains("The output `encoded` was left out"));
207 }
208
209 #[test]
210 fn added_masks_cover_every_form() {
211 let mut log = log(&[]);
212 log.add_mask("line one\nline two");
213 assert_eq!(log.mask("first: line one"), "first: ***");
214 assert_eq!(log.mask("then line two"), "then ***");
215 // Longest first: the whole value, not its pieces.
216 log.add_mask("abc");
217 log.add_mask("abcdef");
218 assert_eq!(log.mask("abcdef"), "***");
219 }
GitHub Actions on g1t, part two: running workflows220}

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