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.
| The balance shows what was spent this month and in total | 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 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 4 | use std::sync::atomic::{AtomicBool, Ordering}; |
| The balance shows what was spent this month and in total | 5 | use std::time::{Duration, Instant}; |
| 6 | ||
| 7 | use anyhow::Result; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 8 | use g1t_actions::mask; |
| The balance shows what was spent this month and in total | 9 | use serde_json::{Value, json}; |
| 10 | ||
| 11 | /// How long log lines wait before they are sent. | |
| 12 | const FLUSH_EVERY: Duration = Duration::from_millis(1500); | |
| 13 | /// How much log is sent at once. | |
| 14 | const FLUSH_BYTES: usize = 64 * 1024; | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 15 | /// The most one step may add to the job's summary, as on GitHub. |
| 16 | pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024; | |
| 17 | /// How long the runner goes without a report before it asks whether the | |
| 18 | /// job was cancelled. | |
| 19 | const PING_EVERY: Duration = Duration::from_secs(10); | |
| 20 | ||
| 21 | /// Whether the job was cancelled, as g1t's answers to its reports say. | |
| 22 | struct Cancel { | |
| 23 | /// g1t said so. | |
| 24 | said: AtomicBool, | |
| 25 | /// The job has taken it in: the step it was on was stopped, and the | |
| 26 | /// cleanup steps that follow are not stopped for it again. | |
| 27 | taken: AtomicBool, | |
| 28 | } | |
| 29 | ||
| 30 | impl Cancel { | |
| 31 | const fn new() -> Cancel { | |
| 32 | Cancel { said: AtomicBool::new(false), taken: AtomicBool::new(false) } | |
| 33 | } | |
| 34 | ||
| 35 | /// Reads an answer to a report: `cancelled` when the run was cancelled. | |
| 36 | fn hear(&self, answer: &Value) { | |
| 37 | if answer["cancelled"].as_bool() == Some(true) { | |
| 38 | self.said.store(true, Ordering::Relaxed); | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | fn cancelled(&self) -> bool { | |
| 43 | self.said.load(Ordering::Relaxed) | |
| 44 | } | |
| The balance shows what was spent this month and in total | 45 | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 46 | fn interrupt(&self) -> bool { |
| 47 | self.cancelled() && !self.taken.load(Ordering::Relaxed) | |
| 48 | } | |
| 49 | ||
| 50 | fn take(&self) { | |
| 51 | self.taken.store(true, Ordering::Relaxed); | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | /// This job's (one runs per process). | |
| 56 | static CANCEL: Cancel = Cancel::new(); | |
| 57 | ||
| 58 | /// Whether g1t said the job was cancelled. | |
| 59 | pub(crate) fn cancelled() -> bool { | |
| 60 | CANCEL.cancelled() | |
| 61 | } | |
| 62 | ||
| 63 | /// Whether a running step should be stopped: cancelled, and not yet taken in. | |
| 64 | pub(crate) fn interrupt() -> bool { | |
| 65 | CANCEL.interrupt() | |
| 66 | } | |
| 67 | ||
| 68 | /// The job has seen the cancellation: what runs from here is its cleanup. | |
| 69 | pub(crate) fn take_cancel() { | |
| 70 | CANCEL.take(); | |
| 71 | } | |
| 72 | ||
| The balance shows what was spent this month and in total | 73 | pub(crate) struct Api { |
| 74 | pub(crate) base: String, | |
| 75 | pub(crate) job: String, | |
| 76 | pub(crate) token: String, | |
| 77 | } | |
| 78 | ||
| 79 | impl Api { | |
| 80 | pub(crate) fn spec(&self) -> Result<Value> { | |
| 81 | let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)) | |
| 82 | .timeout(Duration::from_secs(60)) | |
| 83 | .send_json(json!({ "token": self.token }))?; | |
| 84 | Ok(response.into_json()?) | |
| 85 | } | |
| 86 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 87 | /// Where to fetch another repository's action from: `{"source": "g1t", |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 88 | /// "url", "ref", "token"}`, or `{"source": "github", "sha", "archive", |
| 89 | /// "note"}`: the commit GitHub's ref names, and whether g1t keeps its | |
| 90 | /// archive (`action_archive`). Err holds why g1t | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 91 | /// refused (`true`: the repository is private and may not be used |
| 92 | /// here), or that it could not be asked (`false`). | |
| 93 | pub(crate) fn action(&self, repository: &str, git_ref: &str) -> std::result::Result<Value, (bool, String)> { | |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 94 | // The first job to use an action at a commit waits while g1t |
| 95 | // fetches and keeps its archive. | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 96 | let sent = ureq::post(&format!("{}/actions/jobs/{}/action", self.base, self.job)) |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 97 | .timeout(Duration::from_secs(120)) |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 98 | .send_json(json!({ "token": self.token, "report": { "repository": repository, "ref": git_ref } })); |
| 99 | match sent { | |
| 100 | Ok(response) => response.into_json().map_err(|error| (false, error.to_string())), | |
| 101 | Err(ureq::Error::Status(code, response)) => { | |
| 102 | let body: Value = response.into_json().unwrap_or(Value::Null); | |
| 103 | let message = body["error"]["message"].as_str().unwrap_or("g1t did not answer.").to_owned(); | |
| 104 | Err((code == 403, message)) | |
| 105 | } | |
| 106 | Err(error) => Err((false, error.to_string())), | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 110 | /// Downloads the archive g1t keeps of `repository` at `sha` to `to`. |
| 111 | pub(crate) fn action_archive(&self, repository: &str, sha: &str, to: &std::path::Path) -> std::result::Result<u64, String> { | |
| 112 | let response = ureq::post(&format!("{}/actions/jobs/{}/archive", self.base, self.job)) | |
| 113 | .timeout(Duration::from_secs(300)) | |
| 114 | .send_json(json!({ "token": self.token, "report": { "repository": repository, "sha": sha } })) | |
| 115 | .map_err(|error| match error { | |
| 116 | ureq::Error::Status(code, response) => { | |
| 117 | let body: Value = response.into_json().unwrap_or(Value::Null); | |
| 118 | body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {code}"), str::to_owned) | |
| 119 | } | |
| 120 | other => other.to_string(), | |
| 121 | })?; | |
| 122 | let mut file = std::fs::File::create(to).map_err(|error| error.to_string())?; | |
| 123 | std::io::copy(&mut response.into_reader(), &mut file).map_err(|error| error.to_string()) | |
| 124 | } | |
| 125 | ||
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 126 | /// An API with no address, which sends nothing: what the tests use, so |
| 127 | /// a step's run is not held up by reports that go nowhere (each one | |
| 128 | /// tried three times, with waits between, over a second and a half). | |
| 129 | #[cfg(test)] | |
| 130 | pub(crate) fn offline() -> Api { | |
| 131 | Api { base: String::new(), job: "job_1".into(), token: "t".into() } | |
| 132 | } | |
| 133 | ||
| The balance shows what was spent this month and in total | 134 | pub(crate) fn report(&self, report: Value) { |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 135 | // No address: nowhere to send it (Api::offline). |
| 136 | if self.base.is_empty() { | |
| 137 | return; | |
| 138 | } | |
| The balance shows what was spent this month and in total | 139 | // A report that cannot be sent is tried a few times, then dropped: |
| 140 | // the job goes on, and g1t notices a silent job by itself. | |
| 141 | for attempt in 0..3 { | |
| 142 | let sent = ureq::post(&format!("{}/actions/jobs/{}", self.base, self.job)) | |
| 143 | .timeout(Duration::from_secs(30)) | |
| 144 | .send_json(json!({ "token": self.token, "report": report })); | |
| 145 | match sent { | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 146 | Ok(response) => { |
| 147 | if let Ok(answer) = response.into_json::<Value>() { | |
| 148 | CANCEL.hear(&answer); | |
| 149 | } | |
| 150 | return; | |
| 151 | } | |
| The balance shows what was spent this month and in total | 152 | // Refused: the job was cancelled or finished; nothing to retry. |
| 153 | Err(ureq::Error::Status(code, _)) if (400..500).contains(&code) => return, | |
| 154 | Err(_) => std::thread::sleep(Duration::from_millis(500 * (attempt + 1))), | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 160 | /// The job's log, masked, sent in batches. `masks` holds every form of |
| 161 | /// every secret (`g1t_actions::mask`), longest first. | |
| The balance shows what was spent this month and in total | 162 | pub(crate) struct Log { |
| 163 | pub(crate) api: Api, | |
| 164 | pub(crate) masks: Vec<String>, | |
| 165 | step: u32, | |
| 166 | buffer: String, | |
| 167 | last: Instant, | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 168 | /// When anything was last sent, for the ping. |
| 169 | sent: Instant, | |
| 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. | 170 | /// The problem matchers steps added, which last the whole job. |
| 171 | pub(crate) matchers: super::matchers::Matchers, | |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 172 | /// Everything sent so far, so a test reads the whole log however its |
| 173 | /// batches fell (`buffered`). | |
| 174 | #[cfg(test)] | |
| 175 | flushed: String, | |
| The balance shows what was spent this month and in total | 176 | } |
| 177 | ||
| 178 | impl Log { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 179 | pub(crate) fn new(api: Api, mut masks: Vec<String>) -> Log { |
| 180 | masks.retain(|mask| !mask.is_empty()); | |
| 181 | masks.sort_by_key(|mask| std::cmp::Reverse(mask.len())); | |
| The balance shows what was spent this month and in total | 182 | Log { |
| 183 | api, | |
| 184 | masks, | |
| 185 | step: 0, | |
| 186 | buffer: String::new(), | |
| 187 | last: Instant::now(), | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 188 | sent: 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. | 189 | matchers: super::matchers::Matchers::default(), |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 190 | #[cfg(test)] |
| 191 | flushed: String::new(), | |
| The balance shows what was spent this month and in total | 192 | } |
| 193 | } | |
| 194 | ||
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 195 | /// The log so far: what was sent and what waits to be. |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 196 | #[cfg(all(test, unix))] |
| 197 | pub(crate) fn buffered(&self) -> String { | |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 198 | format!("{}{}", self.flushed, self.buffer) |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 199 | } |
| 200 | ||
| The balance shows what was spent this month and in total | 201 | /// Starts writing to step `number` (0 for the job's setup). |
| 202 | pub(crate) fn step(&mut self, number: u32) { | |
| 203 | self.flush(); | |
| 204 | self.step = number; | |
| 205 | } | |
| 206 | ||
| 207 | pub(crate) fn mask(&self, text: &str) -> String { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 208 | mask::apply(text, &self.masks) |
| 209 | } | |
| 210 | ||
| 211 | /// `::add-mask::`: masks `value` from here on, in every form it can | |
| 212 | /// take (each line, base64, JSON-escaped), as a secret is. | |
| 213 | pub(crate) fn add_mask(&mut self, value: &str) { | |
| 214 | let mut added = false; | |
| 215 | for variant in mask::variants(value) { | |
| 216 | if !self.masks.contains(&variant) { | |
| 217 | self.masks.push(variant); | |
| 218 | added = true; | |
| The balance shows what was spent this month and in total | 219 | } |
| 220 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 221 | if added { |
| 222 | self.masks.sort_by_key(|mask| std::cmp::Reverse(mask.len())); | |
| 223 | } | |
| The balance shows what was spent this month and in total | 224 | } |
| 225 | ||
| 226 | pub(crate) fn line(&mut self, text: &str) { | |
| 227 | let masked = self.mask(text); | |
| 228 | self.buffer.push_str(&masked); | |
| 229 | self.buffer.push('\n'); | |
| 230 | if self.buffer.len() >= FLUSH_BYTES || self.last.elapsed() >= FLUSH_EVERY { | |
| 231 | self.flush(); | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 235 | /// Sends what is waiting if it has waited long enough, and asks after |
| 236 | /// the job when nothing has been sent for a while, so a cancellation | |
| 237 | /// reaches a step that prints nothing. | |
| The balance shows what was spent this month and in total | 238 | pub(crate) fn tick(&mut self) { |
| 239 | if !self.buffer.is_empty() && self.last.elapsed() >= FLUSH_EVERY { | |
| 240 | self.flush(); | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 241 | } else if self.sent.elapsed() >= PING_EVERY { |
| 242 | self.sent = Instant::now(); | |
| 243 | self.api.report(json!({ "kind": "ping" })); | |
| The balance shows what was spent this month and in total | 244 | } |
| 245 | } | |
| 246 | ||
| 247 | pub(crate) fn flush(&mut self) { | |
| 248 | self.last = Instant::now(); | |
| 249 | if self.buffer.is_empty() { | |
| 250 | return; | |
| 251 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 252 | self.sent = Instant::now(); |
| The balance shows what was spent this month and in total | 253 | let text = std::mem::take(&mut self.buffer); |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 254 | #[cfg(test)] |
| 255 | self.flushed.push_str(&text); | |
| The balance shows what was spent this month and in total | 256 | self.api.report(json!({ "kind": "log", "step": self.step, "text": text })); |
| 257 | } | |
| 258 | ||
| 259 | pub(crate) fn steps(&self, names: &[String]) { | |
| 260 | self.api.report(json!({ "kind": "steps", "steps": names })); | |
| 261 | } | |
| 262 | ||
| 263 | pub(crate) fn step_state(&mut self, number: u32, name: &str, status: &str, conclusion: Option<&str>) { | |
| 264 | self.flush(); | |
| 265 | let name = self.mask(name); | |
| 266 | self.api.report(json!({ "kind": "step", "number": number, "name": name, "status": status, "conclusion": conclusion })); | |
| 267 | } | |
| 268 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 269 | /// What a step wrote to `$GITHUB_STEP_SUMMARY`, masked, for the run's |
| 270 | /// page. More than 1 MiB is refused, as on GitHub, with an error in the | |
| 271 | /// log. | |
| 272 | pub(crate) fn summary(&mut self, markdown: &str) { | |
| 273 | if markdown.trim().is_empty() { | |
| 274 | return; | |
| 275 | } | |
| 276 | if markdown.len() > MAX_SUMMARY_BYTES { | |
| 277 | self.line(&format!( | |
| 278 | "##[error]$GITHUB_STEP_SUMMARY upload aborted: a step's summary may be up to 1024k, and this one is {}k.", | |
| 279 | markdown.len().div_ceil(1024) | |
| 280 | )); | |
| 281 | return; | |
| 282 | } | |
| 283 | let markdown = self.mask(markdown); | |
| 284 | self.flush(); | |
| 285 | self.api.report(json!({ "kind": "summary", "step": self.step, "markdown": markdown })); | |
| 286 | } | |
| 287 | ||
| 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. | 288 | /// An annotation, from `::error::` and its kin or a problem matcher: |
| 289 | /// its `file`, `line`, `col`, `endLine` and `endColumn` as GitHub's | |
| 290 | /// commands spell them, sent as `file`, `line`, `column`, `end_line` | |
| 291 | /// and `end_column`. | |
| The balance shows what was spent this month and in total | 292 | pub(crate) fn annotation(&mut self, level: &str, message: &str, properties: &serde_json::Map<String, 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. | 293 | let report = self.annotation_report(level, message, properties); |
| 294 | self.api.report(report); | |
| 295 | } | |
| 296 | ||
| 297 | fn annotation_report(&self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) -> Value { | |
| The balance shows what was spent this month and in total | 298 | let message = self.mask(message); |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 299 | let title = properties.get("title").and_then(Value::as_str).map(|title| self.mask(title)); |
| 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. | 300 | 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()); |
| 301 | json!({ | |
| The balance shows what was spent this month and in total | 302 | "kind": "annotation", |
| 303 | "level": level, | |
| 304 | "message": message, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 305 | "title": title, |
| The balance shows what was spent this month and in total | 306 | "file": properties.get("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. | 307 | "line": number(&["line"]), |
| 308 | "column": number(&["col"]), | |
| 309 | "end_line": number(&["endLine"]), | |
| 310 | "end_column": number(&["endColumn"]), | |
| 311 | }) | |
| The balance shows what was spent this month and in total | 312 | } |
| 313 | ||
| 314 | pub(crate) fn done(&mut self, conclusion: &str, outputs: &serde_json::Map<String, Value>, reason: Option<&str>) { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 315 | let outputs = self.withhold_secrets(outputs); |
| The balance shows what was spent this month and in total | 316 | self.flush(); |
| 317 | self.api.report(json!({ "kind": "done", "conclusion": conclusion, "outputs": outputs, "reason": reason })); | |
| 318 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 319 | |
| 320 | /// The job's outputs without any that hold a secret, as on GitHub: an | |
| 321 | /// output goes to other jobs and to the run's page, where no mask | |
| 322 | /// reaches. Each one left out is warned of in the log. | |
| 323 | pub(crate) fn withhold_secrets(&mut self, outputs: &serde_json::Map<String, Value>) -> serde_json::Map<String, Value> { | |
| 324 | let mut kept = serde_json::Map::new(); | |
| 325 | for (name, value) in outputs { | |
| 326 | let text = match value { | |
| 327 | Value::String(text) => text.clone(), | |
| 328 | other => other.to_string(), | |
| 329 | }; | |
| 330 | if mask::reveals(&text, &self.masks) { | |
| 331 | self.line(&format!("##[warning]The output `{name}` was left out: it holds a secret.")); | |
| 332 | continue; | |
| 333 | } | |
| 334 | kept.insert(name.clone(), value.clone()); | |
| 335 | } | |
| 336 | kept | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | #[cfg(test)] | |
| 341 | mod tests { | |
| 342 | use super::*; | |
| 343 | ||
| 344 | fn log(secrets: &[&str]) -> Log { | |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 345 | Log::new(Api::offline(), mask::all_variants(secrets.iter().copied())) |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 346 | } |
| 347 | ||
| 348 | #[test] | |
| 349 | fn outputs_holding_a_secret_are_left_out() { | |
| 350 | let mut log = log(&["s3cr3t-token"]); | |
| 351 | let mut outputs = serde_json::Map::new(); | |
| 352 | outputs.insert("version".into(), json!("1.2.0")); | |
| 353 | outputs.insert("leak".into(), json!("token=s3cr3t-token")); | |
| 354 | outputs.insert("encoded".into(), json!("czNjcjN0LXRva2Vu")); | |
| 355 | let kept = log.withhold_secrets(&outputs); | |
| 356 | assert_eq!(kept.keys().collect::<Vec<_>>(), ["version"]); | |
| 357 | assert!(log.buffer.contains("The output `leak` was left out")); | |
| 358 | assert!(log.buffer.contains("The output `encoded` was left out")); | |
| 359 | } | |
| 360 | ||
| 361 | #[test] | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 362 | fn a_cancelled_answer_is_heard_once_and_taken_in() { |
| 363 | let cancel = Cancel::new(); | |
| 364 | cancel.hear(&json!({ "ok": true })); | |
| 365 | assert!(!cancel.cancelled()); | |
| 366 | cancel.hear(&json!({ "ok": true, "cancelled": true })); | |
| 367 | assert!(cancel.cancelled() && cancel.interrupt()); | |
| 368 | cancel.take(); | |
| 369 | assert!(cancel.cancelled() && !cancel.interrupt(), "cleanup steps are not stopped again"); | |
| 370 | cancel.hear(&json!({ "ok": true, "cancelled": false })); | |
| 371 | assert!(cancel.cancelled(), "a cancelled job stays cancelled"); | |
| 372 | } | |
| 373 | ||
| 374 | #[test] | |
| 375 | fn a_summary_past_its_limit_is_refused_in_the_log() { | |
| 376 | let mut log = log(&[]); | |
| 377 | log.summary(" | |
| 378 | "); | |
| 379 | assert!(log.buffer.is_empty(), "an empty summary is not sent"); | |
| 380 | log.summary(&"x".repeat(MAX_SUMMARY_BYTES + 1)); | |
| 381 | assert!(log.buffer.contains("$GITHUB_STEP_SUMMARY upload aborted")); | |
| 382 | assert!(log.buffer.contains("1025k")); | |
| 383 | } | |
| 384 | ||
| 385 | #[test] | |
| 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. | 386 | fn annotations_carry_their_columns_and_end() { |
| 387 | let log = log(&["hidden"]); | |
| 388 | let properties: serde_json::Map<String, Value> = | |
| 389 | serde_json::from_value(json!({ "file": "a.rs", "line": "3", "col": "7", "endLine": "4", "endColumn": "2", "title": "t hidden" })).unwrap(); | |
| 390 | let report = log.annotation_report("error", "bad hidden", &properties); | |
| 391 | assert_eq!(report["line"], 3); | |
| 392 | assert_eq!(report["column"], 7); | |
| 393 | assert_eq!(report["end_line"], 4); | |
| 394 | assert_eq!(report["end_column"], 2); | |
| 395 | assert_eq!(report["title"], "t ***"); | |
| 396 | assert_eq!(report["message"], "bad ***"); | |
| 397 | let bare = log.annotation_report("notice", "x", &serde_json::Map::new()); | |
| 398 | assert!(bare["column"].is_null() && bare["file"].is_null()); | |
| 399 | } | |
| 400 | ||
| 401 | #[test] | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 402 | fn added_masks_cover_every_form() { |
| 403 | let mut log = log(&[]); | |
| 404 | log.add_mask("line one\nline two"); | |
| 405 | assert_eq!(log.mask("first: line one"), "first: ***"); | |
| 406 | assert_eq!(log.mask("then line two"), "then ***"); | |
| 407 | // Longest first: the whole value, not its pieces. | |
| 408 | log.add_mask("abc"); | |
| 409 | log.add_mask("abcdef"); | |
| 410 | assert_eq!(log.mask("abcdef"), "***"); | |
| 411 | } | |
| The balance shows what was spent this month and in total | 412 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.