Skip to content
1,125 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.

Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21//! Artifacts and the cache: `actions/upload-artifact`, its `merge`,
The statement is a month at a time, a line per kind of charge2//! `actions/download-artifact` and `actions/cache`, done natively against
Fast pages, required checks on the branch, self-hosted runners, honest incidents3//! g1t. Artifacts belong to the run; cache entries to the repository, found
4//! by exact key or by the newest under a `restore-keys` prefix.
5//!
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R26//! An artifact is a ZIP file, as GitHub's v4 actions make it (zip.rs), of
7//! the files its `path` finds (glob.rs): uploaded in parts with its SHA-256,
8//! and downloaded straight to a file before it is unpacked.
9//!
Fast pages, required checks on the branch, self-hosted runners, honest incidents10//! A cache entry is a tar archive, compressed with zstd where the machine
11//! has it (gzip otherwise), of up to 2 GB: uploaded in parts, and
12//! downloaded straight to a file. Its `path` takes globs (`**` included)
Merge branch 'worktree-agent-a3abfcce648e87dca'13//! and `!` patterns that leave paths out, as `actions/cache` does. Each is
14//! saved and found under a version, the hash of its `path` and compression,
15//! and g1t keeps it under the ref whose run saved it.
The statement is a month at a time, a line per kind of charge16
17use std::collections::BTreeMap;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R218use std::io::{Read, Write};
The statement is a month at a time, a line per kind of charge19use std::path::Path;
20use std::process::Command;
Fast pages, required checks on the branch, self-hosted runners, honest incidents21use std::time::{Duration, Instant};
The statement is a month at a time, a line per kind of charge22
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R223use sha2::{Digest, Sha256};
24
The statement is a month at a time, a line per kind of charge25use super::process::{self, Commands, Ended};
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R226use super::{Job, Post, PostRun, glob, zip};
The statement is a month at a time, a line per kind of charge27
Fast pages, required checks on the branch, self-hosted runners, honest incidents28/// The largest cache entry, compressed (`g1t_contracts::actions::CACHE_MAX_ENTRY_BYTES`).
29const MAX_CACHE_ENTRY: u64 = 2 * 1024 * 1024 * 1024;
The statement is a month at a time, a line per kind of charge30
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.31/// How often a long transfer says how far it has got.
32const PROGRESS_EVERY: Duration = Duration::from_secs(5);
33
34/// A transfer's progress as GitHub's cache action words it:
35/// `Received 46137344 of 523190823 (8.8%), 43.9 MBs/sec`.
36fn progress(verb: &str, done: u64, total: Option<u64>, took: Duration) -> String {
37 let rate = done as f64 / 1_048_576.0 / took.as_secs_f64().max(0.001);
38 match total.filter(|total| *total > 0) {
39 Some(total) => format!("{verb} {done} of {total} ({:.1}%), {rate:.1} MBs/sec", done as f64 * 100.0 / total as f64),
40 None => format!("{verb} {done} bytes, {rate:.1} MBs/sec"),
41 }
42}
43
The statement is a month at a time, a line per kind of charge44fn lines(text: &str) -> Vec<String> {
45 text.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with('#')).map(str::to_owned).collect()
46}
47
48fn quote(text: &str) -> String {
49 format!("'{}'", text.replace('\'', "'\\''"))
50}
51
52impl Job {
53 fn url(&self, rest: &str) -> String {
54 format!("{}/actions/jobs/{}/{rest}", self.log.api.base, self.log.api.job)
55 }
56
57 fn auth(&self) -> String {
58 format!("Bearer {}", self.log.api.token)
59 }
60
61 /// Runs a shell line, logging its output; whether it succeeded.
62 fn shell(&mut self, script: &str) -> bool {
63 let mut command = Command::new("bash");
64 command.args(["-c", script]).current_dir(&self.workspace);
65 let mut commands = Commands::default();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts66 matches!(process::run(command, Duration::from_secs(1800).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
The statement is a month at a time, a line per kind of charge67 }
68
Fast pages, required checks on the branch, self-hosted runners, honest incidents69 /// Downloads into `file`, as it comes; `Ok(None)` when there is
70 /// nothing there.
The statement is a month at a time, a line per kind of charge71 fn download(&mut self, rest: &str, file: &Path) -> Result<Option<String>, String> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents72 let response = match ureq::get(&self.url(rest)).set("authorization", &self.auth()).timeout(Duration::from_secs(1800)).call() {
The statement is a month at a time, a line per kind of charge73 Ok(response) => response,
74 Err(ureq::Error::Status(404, _)) => return Ok(None),
75 Err(error) => return Err(error.to_string()),
76 };
77 let matched = response.header("x-g1t-key").map(str::to_owned).unwrap_or_default();
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.78 let total = response.header("content-length").and_then(|n| n.parse::<u64>().ok());
79 let mut out = std::io::BufWriter::new(std::fs::File::create(file).map_err(|e| e.to_string())?);
80 self.receive(&mut response.into_reader().take(MAX_CACHE_ENTRY + 1024), total, |bytes| out.write_all(bytes))?;
81 out.flush().map_err(|e| e.to_string())?;
The statement is a month at a time, a line per kind of charge82 Ok(Some(matched))
83 }
84
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.85 /// Reads a download to its end, handing `each` every piece, and says
86 /// how it is going every few seconds as GitHub's cache and artifact
87 /// actions do (`Received 46137344 of 523190823 (8.8%), 43.9 MBs/sec`),
88 /// so a long download is never a silent step. Its total in bytes.
89 fn receive(&mut self, reader: &mut dyn Read, total: Option<u64>, mut each: impl FnMut(&[u8]) -> std::io::Result<()>) -> Result<u64, String> {
90 let started = Instant::now();
91 let mut told = Instant::now();
92 let mut received = 0u64;
93 let mut buffer = vec![0u8; 256 * 1024];
94 loop {
95 let n = reader.read(&mut buffer).map_err(|e| e.to_string())?;
96 if n == 0 {
97 break;
98 }
99 each(&buffer[..n]).map_err(|e| e.to_string())?;
100 received += n as u64;
101 if told.elapsed() >= PROGRESS_EVERY {
102 told = Instant::now();
103 self.log.line(&progress("Received", received, total, started.elapsed()));
104 }
105 self.log.tick();
106 }
107 Ok(received)
108 }
109
Fast pages, required checks on the branch, self-hosted runners, honest incidents110 /// Saves `file` as the cache entry `key`, in parts. `Ok(false)` when
111 /// the key is already cached.
Merge branch 'worktree-agent-a3abfcce648e87dca'112 fn upload_cache(&mut self, key: &str, version: &str, file: &Path) -> Result<bool, String> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents113 let size = std::fs::metadata(file).map_err(|e| e.to_string())?.len();
114 if size > MAX_CACHE_ENTRY {
115 return Err(format!("it is {} MB, more than a cache entry may be ({} MB)", size / 1_048_576, MAX_CACHE_ENTRY / 1_048_576));
116 }
Merge branch 'worktree-agent-a3abfcce648e87dca'117 let started = match ureq::post(&self.url(&format!("cache/uploads?key={}&size={size}&version={}", urlencode(key), urlencode(version))))
Fast pages, required checks on the branch, self-hosted runners, honest incidents118 .set("authorization", &self.auth())
119 .timeout(Duration::from_secs(60))
120 .call()
121 {
122 Ok(response) => response.into_json::<serde_json::Value>().map_err(|e| e.to_string())?,
123 Err(ureq::Error::Status(409, _)) => return Ok(false),
124 Err(ureq::Error::Status(_, response)) => return Err(refusal(response)),
125 Err(error) => return Err(error.to_string()),
126 };
127 let id = started["id"].as_str().unwrap_or_default().to_owned();
128 let upload = started["upload"].as_str().unwrap_or_default().to_owned();
129 let part_bytes = started["part_bytes"].as_u64().filter(|n| *n > 0).unwrap_or(32 * 1024 * 1024);
130 let base = format!("cache/uploads/{}", urlencode(&id));
131 let sent = self.send_parts(&base, &upload, file, part_bytes);
132 let parts = match sent {
133 Ok(parts) => parts,
134 Err(error) => {
135 let _ = ureq::delete(&self.url(&format!("{base}?upload={}", urlencode(&upload)))).set("authorization", &self.auth()).call();
136 return Err(error);
137 }
138 };
139 ureq::post(&self.url(&format!("{base}/complete?upload={}", urlencode(&upload))))
140 .set("authorization", &self.auth())
141 .timeout(Duration::from_secs(120))
142 .send_json(serde_json::json!({ "size": size, "parts": parts }))
143 .map_err(|e| match e {
144 ureq::Error::Status(_, response) => refusal(response),
145 other => other.to_string(),
146 })?;
147 Ok(true)
148 }
149
150 /// Sends `file` in parts of `part_bytes`; each part's number and etag.
151 fn send_parts(&mut self, base: &str, upload: &str, file: &Path, part_bytes: u64) -> Result<Vec<serde_json::Value>, String> {
152 let mut reader = std::fs::File::open(file).map_err(|e| e.to_string())?;
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.153 let size = reader.metadata().map(|m| m.len()).unwrap_or(0);
154 let (started, mut told, mut sent) = (Instant::now(), Instant::now(), 0u64);
Fast pages, required checks on the branch, self-hosted runners, honest incidents155 let mut parts = Vec::new();
156 let mut buffer = vec![0u8; part_bytes as usize];
157 for number in 1u32.. {
158 let mut filled = 0;
159 while filled < buffer.len() {
160 let read = reader.read(&mut buffer[filled..]).map_err(|e| e.to_string())?;
161 if read == 0 {
162 break;
163 }
164 filled += read;
165 }
166 if filled == 0 && number > 1 {
167 break;
168 }
169 let url = self.url(&format!("{base}/{number}?upload={}", urlencode(upload)));
170 // A part that fails is sent again, twice at most.
171 let mut tries = 0;
172 let answer = loop {
173 tries += 1;
174 match ureq::put(&url).set("authorization", &self.auth()).timeout(Duration::from_secs(600)).send_bytes(&buffer[..filled]) {
175 Ok(response) => break response.into_json::<serde_json::Value>().map_err(|e| e.to_string())?,
176 Err(ureq::Error::Status(status, response)) if status < 500 => return Err(refusal(response)),
177 Err(error) if tries >= 3 => return Err(error.to_string()),
178 Err(_) => std::thread::sleep(Duration::from_secs(2 * tries)),
179 }
180 };
181 parts.push(serde_json::json!({ "part": number, "etag": answer["etag"] }));
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.182 sent += filled as u64;
Fast pages, required checks on the branch, self-hosted runners, honest incidents183 if filled < buffer.len() {
184 break;
185 }
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.186 // More to send: say how far it has got, as a download does.
187 if told.elapsed() >= PROGRESS_EVERY {
188 told = Instant::now();
189 self.log.line(&progress("Sent", sent, Some(size), started.elapsed()));
190 }
191 self.log.tick();
Fast pages, required checks on the branch, self-hosted runners, honest incidents192 }
193 Ok(parts)
194 }
195
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2196 /// A value of the `github` context, as text.
197 fn github_value(&self, key: &str) -> String {
198 match self.contexts.get("github").and_then(|github| github.get(key)) {
199 Some(serde_json::Value::String(text)) => text.clone(),
200 Some(serde_json::Value::Null) | None => String::new(),
201 Some(other) => other.to_string(),
202 }
203 }
204
205 fn home(&self) -> String {
206 self.base_env_value("HOME").unwrap_or_else(|| "/home/node".into())
207 }
208
209 /// `actions/upload-artifact`: the files `path` names, packed into one
210 /// ZIP and uploaded in parts.
The statement is a month at a time, a line per kind of charge211 pub(crate) fn upload_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2212 let name = input(with, "name").unwrap_or("artifact").to_owned();
213 let missing = input(with, "if-no-files-found").unwrap_or("warn").to_ascii_lowercase();
214 let checked = check_name(&name)
215 .and_then(|()| keep(with, true))
216 .and_then(|keep| if matches!(missing.as_str(), "warn" | "error" | "ignore") { Ok(keep) } else { Err(format!("`if-no-files-found` is `{missing}`; it takes warn, error or ignore.")) });
217 let keep = match checked {
218 Ok(keep) => keep,
219 Err(message) => {
220 self.log.line(&format!("##[error]{message}"));
221 return (false, BTreeMap::new());
The statement is a month at a time, a line per kind of charge222 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2223 };
224 let paths = lines(input(with, "path").unwrap_or_default());
225 let found = glob::find(&paths, &self.workspace.display().to_string(), &self.home(), keep.hidden);
226 if found.files.is_empty() {
227 let message = format!("No files were found with the provided path: {}. No artifacts will be uploaded.", paths.join(", "));
228 return match missing.as_str() {
229 "error" => {
230 self.log.line(&format!("##[error]{message}"));
231 (false, BTreeMap::new())
232 }
233 "ignore" => {
234 self.log.line(&message);
235 (true, BTreeMap::new())
236 }
237 _ => {
238 self.log.line(&format!("##[warning]{message}"));
239 (true, BTreeMap::new())
240 }
241 };
242 }
243 self.log.line(&format!("Found {} with the provided path, stored relative to {}.", count(found.files.len(), "file"), found.root));
244 self.send_artifact(&name, &found.files, &keep)
245 }
246
247 /// Packs `files` into a ZIP and uploads it as the artifact `name`;
248 /// whether it worked, and the outputs `upload-artifact` sets.
249 fn send_artifact(&mut self, name: &str, files: &[(String, std::path::PathBuf)], keep: &Keep) -> (bool, BTreeMap<String, String>) {
250 let archive = self.temp.join(format!("artifact-{}.zip", super::rand_id()));
251 let sent = zip::write(&archive, files, keep.level)
252 .map_err(|e| format!("The files could not be packed: {e}"))
253 .and_then(|packed| self.post_artifact(name, &archive, keep).map(|done| (packed, done)));
254 let _ = std::fs::remove_file(&archive);
255 let (packed, done) = match sent {
256 Ok(sent) => sent,
257 Err(error) => {
258 self.log.line(&format!("##[error]{error}"));
The statement is a month at a time, a line per kind of charge259 return (false, BTreeMap::new());
260 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2261 };
262 let kept = done.retention.map(|days| format!(" It is kept for {}.", count(days as usize, "day"))).unwrap_or_default();
263 self.log.line(&format!("Uploaded artifact {name} ({}, {}).{kept}", megabytes(packed.size), count(packed.files, "file")));
264 let url = format!(
265 "{}/{}/actions/runs/{}/artifacts/{}",
266 self.github_value("server_url").trim_end_matches('/'),
267 self.github_value("repository"),
268 self.github_value("run_id"),
269 done.id
270 );
271 self.log.line(&format!("Artifact download URL: {url}"));
272 let mut outputs = BTreeMap::new();
273 outputs.insert("artifact-id".into(), done.id.to_string());
274 outputs.insert("artifact-url".into(), url);
275 outputs.insert("artifact-digest".into(), done.digest);
276 (true, outputs)
277 }
278
279 /// Uploads the ZIP file `archive` as the artifact `name`, in parts,
280 /// giving the upload up when a part or the end fails.
281 fn post_artifact(&mut self, name: &str, archive: &Path, keep: &Keep) -> Result<Sent, String> {
282 let size = std::fs::metadata(archive).map_err(|e| e.to_string())?.len();
283 let digest = sha256_file(archive).map_err(|e| e.to_string())?;
284 let query = format!(
285 "artifacts/uploads?name={}&size={size}&retention_days={}&overwrite={}&format=zip",
286 urlencode(name),
287 keep.retention,
288 keep.overwrite
289 );
290 let failed = |e: ureq::Error| match e {
291 ureq::Error::Status(_, response) => format!("The artifact could not be uploaded: {}", refusal(response)),
292 other => format!("The artifact could not be uploaded: {other}"),
293 };
294 let started = ureq::post(&self.url(&query))
295 .set("authorization", &self.auth())
296 .timeout(Duration::from_secs(60))
297 .call()
298 .map_err(failed)?
299 .into_json::<serde_json::Value>()
300 .map_err(|e| e.to_string())?;
301 let id = number(&started["id"]).ok_or("g1t did not say which artifact the upload is")?;
302 let upload = started["upload"].as_str().unwrap_or_default().to_owned();
303 let part_bytes = started["part_bytes"].as_u64().filter(|n| *n > 0).unwrap_or(32 * 1024 * 1024);
304 let retention = started["retention_days"].as_u64();
305 if let Some(applied) = retention
306 && keep.retention != 0
307 && applied != u64::from(keep.retention)
308 {
309 self.log.line(&format!(
310 "##[notice]The artifact is kept for {}, not the {} asked for: the most this repository keeps artifacts.",
311 count(applied as usize, "day"),
312 keep.retention
313 ));
The statement is a month at a time, a line per kind of charge314 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2315 let base = format!("artifacts/uploads/{id}");
316 let give_up = |job: &Job| {
317 let _ = ureq::delete(&job.url(&format!("{base}?upload={}", urlencode(&upload)))).set("authorization", &job.auth()).call();
318 };
319 let parts = match self.send_parts(&base, &upload, archive, part_bytes) {
320 Ok(parts) => parts,
321 Err(error) => {
322 give_up(self);
323 return Err(format!("The artifact could not be uploaded: {error}"));
The statement is a month at a time, a line per kind of charge324 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2325 };
326 let done = ureq::post(&self.url(&format!("{base}/complete?upload={}", urlencode(&upload))))
327 .set("authorization", &self.auth())
328 .timeout(Duration::from_secs(120))
329 .send_json(serde_json::json!({ "size": size, "parts": parts, "digest": format!("sha256:{digest}") }));
330 let done = match done {
331 Ok(response) => response.into_json::<serde_json::Value>().unwrap_or_default(),
The statement is a month at a time, a line per kind of charge332 Err(error) => {
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2333 give_up(self);
334 return Err(failed(error));
335 }
336 };
337 Ok(Sent { id: number(&done["id"]).unwrap_or(id), digest, retention: retention.map(|d| d as u32).or((keep.retention != 0).then_some(keep.retention)) })
338 }
339
340 /// The artifacts of this run, or of the run `run_id` of this
341 /// repository; one per name, the newest.
342 fn list_artifacts(&mut self, run_id: Option<&str>) -> Result<Vec<Listed>, String> {
343 let rest = match run_id {
344 Some(run) => format!("artifacts?run_id={}", urlencode(run)),
345 None => "artifacts".to_owned(),
346 };
347 let listed = ureq::get(&self.url(&rest))
348 .set("authorization", &self.auth())
349 .timeout(Duration::from_secs(60))
350 .call()
351 .map_err(|e| match e {
352 ureq::Error::Status(_, response) => refusal(response),
353 other => other.to_string(),
354 })?
355 .into_json::<Vec<serde_json::Value>>()
356 .map_err(|e| e.to_string())?;
357 Ok(newest(listed.iter().filter_map(Listed::from_json).collect()))
358 }
359
360 /// Downloads an artifact to a file, as it comes, and unpacks it into
361 /// `into`.
362 fn fetch_artifact(&mut self, artifact: &Listed, into: &Path) -> Result<(), String> {
363 let file = self.temp.join(format!("download-{}.zip", super::rand_id()));
364 let fetched = self.fetch_to(artifact, &file).and_then(|format| {
365 if format == "tgz" {
366 std::fs::create_dir_all(into).map_err(|e| e.to_string())?;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9367 let script = format!("tar{} -xzf {} -C {}", tar_local(), quote(&file.display().to_string()), quote(&into.display().to_string()));
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2368 if self.shell(&script) { Ok(()) } else { Err("it could not be unpacked".into()) }
369 } else {
370 zip::extract(&file, into).map(|_| ()).map_err(|e| format!("it could not be unpacked: {e}"))
371 }
372 });
373 let _ = std::fs::remove_file(&file);
374 fetched
375 }
376
377 /// Downloads an artifact into `file`, checking its digest; how it is
378 /// packed, `zip` or `tgz`.
379 fn fetch_to(&mut self, artifact: &Listed, file: &Path) -> Result<String, String> {
380 let response = match ureq::get(&self.url(&format!("artifacts/{}/download", artifact.id)))
381 .set("authorization", &self.auth())
382 .timeout(Duration::from_secs(4 * 3600))
383 .call()
384 {
385 Ok(response) => response,
386 Err(ureq::Error::Status(404, _)) => return Err("it is gone: it expired or was deleted".into()),
387 Err(ureq::Error::Status(_, response)) => return Err(refusal(response)),
388 Err(error) => return Err(error.to_string()),
389 };
390 let format = response.header("x-g1t-format").map(str::to_owned).unwrap_or_else(|| artifact.format.clone());
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.391 let total = response.header("content-length").and_then(|n| n.parse::<u64>().ok()).or(Some(artifact.size));
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2392 let mut reader = response.into_reader();
393 let mut out = std::io::BufWriter::new(std::fs::File::create(file).map_err(|e| e.to_string())?);
394 let mut hasher = Sha256::new();
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.395 self.receive(&mut reader, total, |bytes| {
396 hasher.update(bytes);
397 out.write_all(bytes)
398 })?;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2399 out.flush().map_err(|e| e.to_string())?;
400 let got = hex::encode(hasher.finalize());
401 if let Some(expected) = artifact.digest.as_deref().map(|d| d.trim_start_matches("sha256:"))
402 && !expected.is_empty()
403 && !expected.eq_ignore_ascii_case(&got)
404 {
405 self.log.line(&format!("##[warning]Artifact {} does not match its digest (sha256:{got}, not sha256:{expected}): it may have been changed since it was uploaded.", artifact.name));
406 }
407 Ok(format)
The statement is a month at a time, a line per kind of charge408 }
409
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2410 /// Where artifacts are downloaded: `path`, under the workspace unless
411 /// absolute, `~` the home folder.
412 fn download_dir(&self, path: Option<&str>) -> std::path::PathBuf {
413 match path {
414 None => self.workspace.clone(),
415 Some(path) => {
416 let home = self.home();
417 let path = if path == "~" {
418 home
419 } else if let Some(rest) = path.strip_prefix("~/") {
420 format!("{}/{rest}", home.trim_end_matches('/'))
421 } else {
422 path.to_owned()
423 };
424 self.workspace.join(path)
The statement is a month at a time, a line per kind of charge425 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2426 }
427 }
428
429 /// `actions/download-artifact`: one artifact by name straight into
430 /// `path`, or the run's artifacts (by `pattern` or `artifact-ids`) each
431 /// into a folder of its name, or all into `path` with `merge-multiple`.
432 pub(crate) fn download_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
433 let fail = |job: &mut Job, message: &str| {
434 job.log.line(&format!("##[error]{message}"));
435 (false, BTreeMap::new())
436 };
437 let name = input(with, "name");
438 let ids = match artifact_ids(input(with, "artifact-ids").unwrap_or_default()) {
439 Ok(ids) => ids,
440 Err(message) => return fail(self, &message),
441 };
442 if name.is_some() && !ids.is_empty() {
443 return fail(self, "Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.");
444 }
445 let merge = match flag(with, "merge-multiple", false) {
446 Ok(merge) => merge,
447 Err(message) => return fail(self, &message),
The statement is a month at a time, a line per kind of charge448 };
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2449 let dest = self.download_dir(input(with, "path"));
450 // Another run's artifacts, as v4 has it: with a token and a run id.
451 let run_id = match (input(with, "github-token"), input(with, "run-id")) {
452 (Some(_), Some(run)) => {
453 let own = self.github_value("repository");
454 let repository = input(with, "repository").unwrap_or(&own);
455 if !repository.eq_ignore_ascii_case(&own) {
456 return fail(self, &format!("g1t downloads artifacts from other runs of the same repository only; {repository} is not this run's repository, {own}."));
The statement is a month at a time, a line per kind of charge457 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2458 Some(run.to_owned())
The statement is a month at a time, a line per kind of charge459 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2460 _ => None,
461 };
462 let listed = match self.list_artifacts(run_id.as_deref()) {
463 Ok(listed) => listed,
464 Err(error) => return fail(self, &format!("The artifacts could not be listed: {error}")),
465 };
466 let chosen: Vec<Listed> = if let Some(name) = name {
467 match listed.into_iter().find(|a| a.name == name) {
468 Some(artifact) => vec![artifact],
469 None => return fail(self, &format!("Unable to download artifact(s): Artifact not found for name: {name}")),
The statement is a month at a time, a line per kind of charge470 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2471 } else if !ids.is_empty() {
472 let chosen: Vec<Listed> = listed.into_iter().filter(|a| ids.contains(&a.id)).collect();
473 if chosen.is_empty() {
474 return fail(self, "Unable to download artifact(s): None of the provided artifact IDs were found.");
475 }
476 if chosen.len() < ids.len() {
477 self.log.line(&format!("##[warning]Could only find {} of {} artifact IDs.", chosen.len(), ids.len()));
478 }
479 chosen
480 } else {
481 match input(with, "pattern") {
482 Some(pattern) => listed.into_iter().filter(|a| glob::matches_part(pattern, &a.name)).collect(),
483 None => listed,
484 }
485 };
The statement is a month at a time, a line per kind of charge486 let mut outputs = BTreeMap::new();
487 outputs.insert("download-path".into(), dest.display().to_string());
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2488 if chosen.is_empty() {
489 match input(with, "pattern") {
490 Some(pattern) => self.log.line(&format!("No artifacts matched the pattern {pattern}; nothing was downloaded.")),
491 None => self.log.line("The run has no artifacts; nothing was downloaded."),
492 }
493 return (true, outputs);
494 }
495 let targets = download_targets(&dest, &chosen, name.is_some(), !ids.is_empty(), merge);
496 for (artifact, target) in chosen.iter().zip(targets) {
497 let Some(target) = target else {
498 return fail(self, &format!("The artifact {} cannot be downloaded into a folder of its name.", artifact.name));
499 };
500 if let Err(error) = self.fetch_artifact(artifact, &target) {
501 return fail(self, &format!("The artifact {} could not be downloaded: {error}", artifact.name));
502 }
503 self.log.line(&format!("Downloaded artifact {} ({}) into {}", artifact.name, megabytes(artifact.size), target.display()));
504 }
505 if chosen.len() > 1 {
506 self.log.line(&format!("Downloaded {}.", count(chosen.len(), "artifact")));
507 }
The statement is a month at a time, a line per kind of charge508 (true, outputs)
509 }
510
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2511 /// `actions/upload-artifact/merge`: the run's artifacts matching
512 /// `pattern`, downloaded together and uploaded again as one.
513 pub(crate) fn merge_artifacts(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
514 let fail = |job: &mut Job, message: &str| {
515 job.log.line(&format!("##[error]{message}"));
516 (false, BTreeMap::new())
517 };
518 let name = input(with, "name").unwrap_or("merged-artifacts").to_owned();
519 let pattern = input(with, "pattern").unwrap_or("*").to_owned();
520 let options = check_name(&name).and_then(|()| {
521 let keep = keep(with, false)?;
522 Ok((keep, flag(with, "separate-directories", false)?, flag(with, "delete-merged", false)?))
523 });
524 let (keep, separate, delete) = match options {
525 Ok(options) => options,
526 Err(message) => return fail(self, &message),
527 };
528 let listed = match self.list_artifacts(None) {
529 Ok(listed) => listed,
530 Err(error) => return fail(self, &format!("The artifacts could not be listed: {error}")),
531 };
532 let chosen: Vec<Listed> = listed.into_iter().filter(|a| glob::matches_part(&pattern, &a.name)).collect();
533 if chosen.is_empty() {
534 return fail(self, &format!("No artifacts found matching pattern '{pattern}'."));
535 }
536 self.log.line(&format!("Merging {}: {}", count(chosen.len(), "artifact"), chosen.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", ")));
537 let folder = self.temp.join(format!("merge-{}", super::rand_id()));
538 let targets = download_targets(&folder, &chosen, false, false, !separate);
539 let mut result = None;
540 for (artifact, target) in chosen.iter().zip(targets) {
541 let Some(target) = target else {
542 result = Some(fail(self, &format!("The artifact {} cannot be merged into a folder of its name.", artifact.name)));
543 break;
544 };
545 if let Err(error) = self.fetch_artifact(artifact, &target) {
546 result = Some(fail(self, &format!("The artifact {} could not be downloaded: {error}", artifact.name)));
547 break;
548 }
549 }
550 let result = result.unwrap_or_else(|| {
551 let root = folder.display().to_string();
552 let found = glob::find(std::slice::from_ref(&root), &root, &self.home(), keep.hidden);
553 if found.files.is_empty() {
554 fail(self, "The artifacts matched hold no files to merge.")
555 } else {
556 self.send_artifact(&name, &found.files, &keep)
557 }
558 });
559 let _ = std::fs::remove_dir_all(&folder);
560 if result.0 && delete {
561 for artifact in &chosen {
562 match ureq::delete(&self.url(&format!("artifacts/{}", artifact.id))).set("authorization", &self.auth()).timeout(Duration::from_secs(60)).call() {
563 Ok(_) => self.log.line(&format!("Deleted artifact {}.", artifact.name)),
564 Err(ureq::Error::Status(_, response)) => self.log.line(&format!("##[warning]Artifact {} could not be deleted: {}", artifact.name, refusal(response))),
565 Err(error) => self.log.line(&format!("##[warning]Artifact {} could not be deleted: {error}", artifact.name)),
566 }
567 }
568 }
569 result
570 }
571
Fast pages, required checks on the branch, self-hosted runners, honest incidents572 /// The cache's `path`, each made absolute (`~/` is the home folder,
573 /// anything else is under the workspace), `!` patterns kept as they
574 /// came, with their `!`.
The statement is a month at a time, a line per kind of charge575 fn cache_paths(&self, with: &BTreeMap<String, String>) -> Vec<String> {
576 let home = self.base_env_value("HOME").unwrap_or_else(|| "/home/node".into());
Fast pages, required checks on the branch, self-hosted runners, honest incidents577 let workspace = self.workspace.display().to_string();
578 cache_patterns(with.get("path").map(String::as_str).unwrap_or_default(), &home, &workspace)
The statement is a month at a time, a line per kind of charge579 }
580
581 /// `actions/cache` and `actions/cache/restore`: restores what it can,
582 /// and for `actions/cache`, saves at the end of the job on a miss.
583 pub(crate) fn cache(&mut self, with: &BTreeMap<String, String>, save_after: bool, title: &str) -> (bool, BTreeMap<String, String>) {
584 let key = with.get("key").cloned().unwrap_or_default();
585 if key.is_empty() {
586 self.log.line("##[error]The cache needs a `key`.");
587 return (false, BTreeMap::new());
588 }
589 let paths = self.cache_paths(with);
Merge branch 'worktree-agent-a3abfcce648e87dca'590 let version = cache_version(with.get("path").map(String::as_str).unwrap_or_default(), compression());
The statement is a month at a time, a line per kind of charge591 let restore = lines(with.get("restore-keys").map(String::as_str).unwrap_or_default());
592 let query = format!(
Merge branch 'worktree-agent-a3abfcce648e87dca'593 "cache?key={}&restore={}&version={}",
The statement is a month at a time, a line per kind of charge594 urlencode(&key),
Merge branch 'worktree-agent-a3abfcce648e87dca'595 urlencode(&restore.join("\n")),
596 urlencode(&version)
The statement is a month at a time, a line per kind of charge597 );
Fast pages, required checks on the branch, self-hosted runners, honest incidents598 let archive = self.temp.join("cache-restore.tar");
599 let started = Instant::now();
The statement is a month at a time, a line per kind of charge600 let mut outputs = BTreeMap::new();
601 let exact = match self.download(&query, &archive) {
602 Ok(Some(matched)) => {
603 let lookup_only = with.get("lookup-only").is_some_and(|v| v == "true");
Fast pages, required checks on the branch, self-hosted runners, honest incidents604 let size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
605 // tar finds out from the archive whether it is zstd or gzip.
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9606 if !lookup_only && !self.shell(&format!("tar{} -xPf {}", tar_local(), quote(&archive.display().to_string()))) {
The statement is a month at a time, a line per kind of charge607 self.log.line("##[warning]The cache was found but could not be unpacked.");
608 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents609 let _ = std::fs::remove_file(&archive);
610 self.log.line(&format!("Cache restored from key: {matched} ({} in {:.1}s)", megabytes(size), started.elapsed().as_secs_f64()));
The statement is a month at a time, a line per kind of charge611 outputs.insert("cache-matched-key".into(), matched.clone());
612 matched == key
613 }
614 Ok(None) => {
615 self.log.line(&format!("Cache not found for input keys: {}", std::iter::once(key.clone()).chain(restore).collect::<Vec<_>>().join(", ")));
616 if with.get("fail-on-cache-miss").is_some_and(|v| v == "true") {
617 self.log.line("##[error]The cache missed, and `fail-on-cache-miss` is set.");
618 return (false, outputs);
619 }
620 false
621 }
622 Err(error) => {
623 self.log.line(&format!("##[warning]The cache could not be read: {error}"));
624 false
625 }
626 };
627 outputs.insert("cache-hit".into(), exact.to_string());
628 outputs.insert("cache-primary-key".into(), key.clone());
629 if save_after && !exact {
630 self.posts.push(Post {
631 name: format!("Post {title}"),
632 condition: "success()".into(),
633 env: BTreeMap::new(),
Merge branch 'worktree-agent-a3abfcce648e87dca'634 run: PostRun::CacheSave { key, paths, version },
The statement is a month at a time, a line per kind of charge635 });
636 }
637 (true, outputs)
638 }
639
640 /// Saves paths under a key, unless the key is taken.
Merge branch 'worktree-agent-a3abfcce648e87dca'641 pub(crate) fn cache_save(&mut self, key: &str, paths: &[String], version: &str) -> bool {
Fast pages, required checks on the branch, self-hosted runners, honest incidents642 if paths.iter().all(|p| p.starts_with('!')) {
The statement is a month at a time, a line per kind of charge643 self.log.line("##[warning]Nothing to cache: no `path`.");
644 return true;
645 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents646 let archive = self.temp.join("cache-save.tar");
647 let started = Instant::now();
648 match self.run_shell(&pack_script(paths, &archive.display().to_string())) {
649 Some(0) => {}
650 Some(3) => {
651 self.log.line("##[warning]None of the cache's paths exist; nothing was saved.");
652 return true;
653 }
654 _ => {
655 self.log.line("##[warning]The cache could not be packed; nothing was saved.");
656 return true;
657 }
The statement is a month at a time, a line per kind of charge658 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents659 let size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
660 let packed = started.elapsed().as_secs_f64();
Merge branch 'worktree-agent-a3abfcce648e87dca'661 match self.upload_cache(key, version, &archive) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents662 Ok(true) => self.log.line(&format!(
663 "Cache saved with key: {key} ({}, packed in {packed:.1}s, sent in {:.1}s)",
664 megabytes(size),
665 started.elapsed().as_secs_f64() - packed
666 )),
667 Ok(false) => self.log.line(&format!("Cache not saved: {key} is already cached.")),
The statement is a month at a time, a line per kind of charge668 // A cache that cannot be saved does not fail the job, as on GitHub.
669 Err(error) => self.log.line(&format!("##[warning]The cache could not be saved: {error}")),
670 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents671 let _ = std::fs::remove_file(&archive);
The statement is a month at a time, a line per kind of charge672 true
673 }
674
Fast pages, required checks on the branch, self-hosted runners, honest incidents675 /// Runs a shell line, logging its output; its exit code.
676 fn run_shell(&mut self, script: &str) -> Option<i32> {
677 let mut command = Command::new("bash");
678 command.args(["-c", script]).current_dir(&self.workspace);
679 let mut commands = Commands::default();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts680 match process::run(command, Duration::from_secs(1800).min(self.remaining_time()), &mut self.log, &mut commands) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents681 Ok(Ended::Exited(code)) => Some(code),
682 _ => None,
683 }
684 }
685
The statement is a month at a time, a line per kind of charge686 /// `actions/cache/save`.
687 pub(crate) fn cache_save_now(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
688 let key = with.get("key").cloned().unwrap_or_default();
689 let paths = self.cache_paths(with);
Merge branch 'worktree-agent-a3abfcce648e87dca'690 let version = cache_version(with.get("path").map(String::as_str).unwrap_or_default(), compression());
691 (self.cache_save(&key, &paths, &version), BTreeMap::new())
The statement is a month at a time, a line per kind of charge692 }
693}
694
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2695/// An input, trimmed; `None` when empty.
696fn input<'a>(with: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
697 with.get(key).map(|v| v.trim()).filter(|v| !v.is_empty())
698}
699
700/// A true-or-false input, as `core.getBooleanInput` reads it.
701fn flag(with: &BTreeMap<String, String>, key: &str, default: bool) -> Result<bool, String> {
702 match input(with, key) {
703 None => Ok(default),
704 Some("true" | "True" | "TRUE") => Ok(true),
705 Some("false" | "False" | "FALSE") => Ok(false),
706 Some(other) => Err(format!("`{key}` is `{other}`; it takes true or false.")),
707 }
708}
709
710/// `1 file`, `3 files`.
711fn count(n: usize, what: &str) -> String {
712 if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") }
713}
714
715/// An id that came as a number or as text.
716fn number(value: &serde_json::Value) -> Option<u64> {
717 value.as_u64().or_else(|| value.as_str().and_then(|s| s.parse().ok()))
718}
719
720/// Whether a name is one GitHub takes for an artifact: not empty, at most
721/// 256 characters, none of `" : < > | * ? \ /` or a line break.
722fn check_name(name: &str) -> Result<(), String> {
723 if name.is_empty() {
724 return Err("The artifact needs a name.".into());
725 }
726 if name.chars().count() > 256 {
727 return Err(format!("The artifact name `{name}` is longer than 256 characters."));
728 }
729 if let Some(bad) = name.chars().find(|c| matches!(c, '"' | ':' | '<' | '>' | '|' | '*' | '?' | '\r' | '\n' | '\\' | '/')) {
730 let shown = match bad {
731 '\r' => "a carriage return".to_owned(),
732 '\n' => "a line break".to_owned(),
733 c => format!("`{c}`"),
734 };
735 return Err(format!("The artifact name `{name}` is not valid: it contains {shown}. A name may not contain \" : < > | * ? \\ / or line breaks."));
736 }
737 Ok(())
738}
739
740/// How an artifact is packed and kept, from the inputs `upload-artifact`
741/// and its merge share.
742#[derive(Debug, PartialEq)]
743struct Keep {
744 /// Days; 0 for the repository's own setting.
745 retention: u32,
746 /// 0 (stored) to 9.
747 level: u32,
748 overwrite: bool,
749 /// Whether files and folders whose names start with `.` are taken.
750 hidden: bool,
751}
752
753fn keep(with: &BTreeMap<String, String>, takes_overwrite: bool) -> Result<Keep, String> {
754 let retention = match input(with, "retention-days") {
755 None => 0,
756 Some(text) => match text.parse::<u32>() {
757 Ok(days @ 0..=90) => days,
758 _ => return Err(format!("`retention-days` is `{text}`; it takes a number of days from 1 to 90, or nothing for the repository's setting.")),
759 },
760 };
761 let level = match input(with, "compression-level") {
762 None => 6,
763 Some(text) => match text.parse::<u32>() {
764 Ok(level @ 0..=9) => level,
765 _ => return Err(format!("`compression-level` is `{text}`; it takes 0 (no compression) to 9.")),
766 },
767 };
768 Ok(Keep {
769 retention,
770 level,
771 overwrite: takes_overwrite && flag(with, "overwrite", false)?,
772 hidden: flag(with, "include-hidden-files", false)?,
773 })
774}
775
776/// What finishing an upload gave: the artifact's id, the ZIP's SHA-256 in
777/// hex, and the days it is kept, when known.
778struct Sent {
779 id: u64,
780 digest: String,
781 retention: Option<u32>,
782}
783
784/// An artifact as g1t lists it.
785#[derive(Debug, Clone, PartialEq)]
786struct Listed {
787 id: u64,
788 name: String,
789 size: u64,
790 digest: Option<String>,
791 /// `zip`, or `tgz` for an artifact an older runner stored.
792 format: String,
793}
794
795impl Listed {
796 fn from_json(value: &serde_json::Value) -> Option<Listed> {
797 Some(Listed {
798 id: number(&value["id"])?,
799 name: value["name"].as_str()?.to_owned(),
800 size: value["size"].as_u64().unwrap_or(0),
801 digest: value["digest"].as_str().map(str::to_owned),
802 format: value["format"].as_str().unwrap_or("zip").to_owned(),
803 })
804 }
805}
806
807/// One artifact per name, the newest (highest id), in name order.
808fn newest(listed: Vec<Listed>) -> Vec<Listed> {
809 let mut by_name: BTreeMap<String, Listed> = BTreeMap::new();
810 for artifact in listed {
811 if by_name.get(&artifact.name).is_none_or(|kept| kept.id < artifact.id) {
812 by_name.insert(artifact.name.clone(), artifact);
813 }
814 }
815 by_name.into_values().collect()
816}
817
818/// `artifact-ids`: numbers, separated by commas.
819fn artifact_ids(text: &str) -> Result<Vec<u64>, String> {
820 text.split(',')
821 .map(str::trim)
822 .filter(|id| !id.is_empty())
823 .map(|id| id.parse::<u64>().map_err(|_| format!("`artifact-ids` takes artifact ids, numbers separated by commas; `{id}` is not one.")))
824 .collect()
825}
826
827/// The folder each artifact is unpacked into: `dest` itself for one
828/// artifact by name, for `merge-multiple`, and for a single artifact by
829/// id; otherwise a folder of the artifact's name in `dest`. `None` for a
830/// name that cannot be a folder.
831fn download_targets(dest: &Path, chosen: &[Listed], by_name: bool, by_ids: bool, merge: bool) -> Vec<Option<std::path::PathBuf>> {
832 let straight = by_name || merge || (by_ids && chosen.len() == 1);
833 chosen
834 .iter()
835 .map(|artifact| {
836 if straight {
837 Some(dest.to_path_buf())
838 } else if artifact.name.is_empty() || artifact.name == "." || artifact.name == ".." || artifact.name.contains(['/', '\\']) {
839 None
840 } else {
841 Some(dest.join(&artifact.name))
842 }
843 })
844 .collect()
845}
846
847/// The SHA-256 of a file, in hex, read as it comes.
848fn sha256_file(path: &Path) -> std::io::Result<String> {
849 let mut file = std::fs::File::open(path)?;
850 let mut hasher = Sha256::new();
851 let mut buffer = vec![0u8; 256 * 1024];
852 loop {
853 let n = file.read(&mut buffer)?;
854 if n == 0 {
855 break;
856 }
857 hasher.update(&buffer[..n]);
858 }
859 Ok(hex::encode(hasher.finalize()))
860}
861
Fast pages, required checks on the branch, self-hosted runners, honest incidents862/// The message of a refused request, from its JSON body.
863fn refusal(response: ureq::Response) -> String {
864 let status = response.status();
865 let body: serde_json::Value = response.into_json().unwrap_or_default();
866 body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {status}"), str::to_owned)
867}
868
869fn megabytes(bytes: u64) -> String {
870 if bytes < 1_048_576 { format!("{} KB", bytes.div_ceil(1024)) } else { format!("{:.1} MB", bytes as f64 / 1_048_576.0) }
871}
872
Merge branch 'worktree-agent-a3abfcce648e87dca'873/// How this machine compresses cache entries: zstd where it has it, else
874/// gzip, as `pack_script` decides.
875fn compression() -> &'static str {
876 let zstd = Command::new("sh").args(["-c", "command -v zstd"]).output().is_ok_and(|out| out.status.success());
877 if zstd { "zstd" } else { "gzip" }
878}
879
880/// An entry's version: the hash of its `path` lines, as written, and its
881/// compression, as `actions/cache` makes one. The same key saved for other
882/// paths, or packed another way, is another entry.
883fn cache_version(path: &str, compression: &str) -> String {
884 use sha2::{Digest, Sha256};
885 let mut parts = lines(path);
886 parts.push(compression.to_owned());
887 hex::encode(Sha256::digest(parts.join("|").as_bytes()))
888}
889
Fast pages, required checks on the branch, self-hosted runners, honest incidents890/// The lines of a cache's `path`, absolute: `~/` is `home`, a relative
891/// path is under `workspace`. A `!` pattern keeps its `!`.
892fn cache_patterns(path: &str, home: &str, workspace: &str) -> Vec<String> {
893 lines(path)
894 .into_iter()
895 .map(|line| {
896 let (bang, p) = match line.strip_prefix('!') {
897 Some(rest) => ("!", rest.trim().to_owned()),
898 None => ("", line),
899 };
900 let p = if p == "~" {
901 home.to_owned()
902 } else if let Some(rest) = p.strip_prefix("~/") {
903 format!("{home}/{rest}")
904 } else {
905 p
906 };
907 let p = if p.starts_with('/') { p } else { format!("{}/{}", workspace.trim_end_matches('/'), p.trim_start_matches("./")) };
908 format!("{bang}{}", p.trim_end_matches('/'))
909 })
910 .collect()
911}
912
913/// A path pattern as a word bash expands as a glob: everything but `*`,
914/// `?` and `[...]` escaped, so spaces and quotes stay literal.
915fn glob_word(pattern: &str) -> String {
916 let mut out = String::new();
917 for c in pattern.chars() {
918 if c.is_ascii_alphanumeric() || matches!(c, '*' | '?' | '[' | ']' | '/' | '.' | '-' | '_' | '~' | '+' | ',' | '=' | '@' | ':') {
919 out.push(c);
920 } else {
921 out.push('\\');
922 out.push(c);
923 }
924 }
925 out
926}
927
928/// The script that packs a cache: its patterns expanded (`**` reaching
929/// any depth), `!` patterns left out, into a tar archive compressed with
930/// zstd where there is one, else gzip. Exits 3 when nothing matched.
931fn pack_script(patterns: &[String], archive: &str) -> String {
932 let includes: Vec<String> = patterns.iter().filter(|p| !p.starts_with('!')).map(|p| glob_word(p)).collect();
933 let excludes: Vec<String> = patterns
934 .iter()
935 .filter_map(|p| p.strip_prefix('!'))
936 // tar's patterns: `*` already crosses `/`, so `**` is the same.
937 .map(|p| format!("--exclude={}", quote(&p.replace("**", "*"))))
938 .collect();
939 format!(
940 "set -o pipefail; shopt -s globstar nullglob dotglob; found=( {} ); files=(); \
941 for f in \"${{found[@]}}\"; do if [ -e \"$f\" ]; then files+=(\"$f\"); fi; done; \
942 if [ ${{#files[@]}} -eq 0 ]; then exit 3; fi; \
943 if command -v zstd >/dev/null; then compress='zstd -T0 -3'; else compress=gzip; fi; \
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9944 tar{} -cPf {} -I \"$compress\" {} -- \"${{files[@]}}\"",
Fast pages, required checks on the branch, self-hosted runners, honest incidents945 includes.join(" "),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9946 tar_local(),
Fast pages, required checks on the branch, self-hosted runners, honest incidents947 quote(archive),
948 excludes.join(" ")
949 )
950}
951
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9952/// GNU tar on Windows reads `C:...` as a host and a file; `--force-local`
953/// makes every path a local one. Elsewhere the flag is left off, since
954/// bsdtar (macOS) doesn't know it.
955pub(crate) fn tar_local() -> &'static str {
956 if cfg!(windows) {
957 " --force-local"
958 } else {
959 ""
960 }
961}
962
The statement is a month at a time, a line per kind of charge963fn urlencode(text: &str) -> String {
964 let mut out = String::new();
965 for byte in text.bytes() {
966 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
967 out.push(byte as char);
968 } else {
969 out.push_str(&format!("%{byte:02X}"));
970 }
971 }
972 out
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978
979 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'980 fn a_cache_entrys_version_follows_its_paths_and_compression() {
981 let version = cache_version("~/.cargo/registry\ntarget", "zstd");
982 assert_eq!(version.len(), 64);
983 assert_eq!(version, cache_version("~/.cargo/registry\n\ntarget\n", "zstd"), "blank lines do not count");
984 assert_ne!(version, cache_version("~/.cargo/registry", "zstd"));
985 assert_ne!(version, cache_version("~/.cargo/registry\ntarget", "gzip"));
986 }
987
988 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents989 fn cache_paths_take_home_globs_and_exclusions() {
990 let paths = cache_patterns("~/.cargo/registry/cache\ntarget/*/release/\n!target/**/incremental\n./dist\n/abs/x\n~", "/home/node", "/w/repo/");
991 assert_eq!(
992 paths,
993 ["/home/node/.cargo/registry/cache", "/w/repo/target/*/release", "!/w/repo/target/**/incremental", "/w/repo/dist", "/abs/x", "/home/node"]
994 );
995 assert_eq!(glob_word("/w/my repo/target/**/*.rlib"), "/w/my\\ repo/target/**/*.rlib");
996 assert_eq!(glob_word("/w/a'b"), "/w/a\\'b");
997 }
998
999 #[test]
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r91000 fn packing_keeps_windows_paths_local() {
1001 let script = pack_script(&["C:/w/target".into()], "C:/t/c.tar");
1002 assert_eq!(script.contains("--force-local"), cfg!(windows), "{script}");
1003 }
1004
1005 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1006 fn packing_expands_globs_and_leaves_exclusions_out() {
1007 let script = pack_script(&["/w/target/*/release".into(), "!/w/target/**/incremental".into()], "/t/c.tar");
1008 assert!(script.contains("found=( /w/target/*/release )"), "{script}");
1009 assert!(script.contains("--exclude='/w/target/*/incremental'"), "{script}");
1010 assert!(script.contains("zstd -T0"), "{script}");
1011 assert!(script.contains("exit 3"), "{script}");
1012 }
1013
1014 /// Packs and unpacks for real, where bash and tar are (not on Windows).
1015 #[test]
1016 #[cfg(unix)]
1017 fn a_packed_cache_unpacks_without_what_was_left_out() {
1018 let dir = std::env::temp_dir().join(format!("g1t-cache-test-{}", std::process::id()));
1019 let _ = std::fs::remove_dir_all(&dir);
1020 for file in ["target/release/deps/a.rlib", "target/release/incremental/x.bin", "target/wasm/release/deps/b.rlib", "src/main.rs"] {
1021 let path = dir.join(file);
1022 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1023 std::fs::write(&path, file).unwrap();
1024 }
1025 let root = dir.display().to_string();
1026 let patterns = cache_patterns("target/**/deps\ntarget/release/incremental\n!target/**/incremental", "/home/node", &root);
1027 let archive = dir.join("c.tar").display().to_string();
1028 let status = Command::new("bash").args(["-c", &pack_script(&patterns, &archive)]).status().unwrap();
1029 assert!(status.success());
1030 let listed = Command::new("tar").args(["-tPf", &archive]).output().unwrap();
1031 let listed = String::from_utf8_lossy(&listed.stdout);
1032 assert!(listed.contains("deps/a.rlib") && listed.contains("deps/b.rlib"), "{listed}");
1033 assert!(!listed.contains("incremental") && !listed.contains("main.rs"), "{listed}");
1034 let none = Command::new("bash").args(["-c", &pack_script(&[format!("{root}/nothing/*")], &archive)]).status().unwrap();
1035 assert_eq!(none.code(), Some(3));
1036 let _ = std::fs::remove_dir_all(&dir);
1037 }
1038
1039 #[test]
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1040 fn transfers_say_how_far_they_have_got_as_githubs_do() {
1041 assert_eq!(progress("Received", 46_137_344, Some(523_190_823), Duration::from_secs(1)), "Received 46137344 of 523190823 (8.8%), 44.0 MBs/sec");
1042 assert_eq!(progress("Sent", 1_048_576, None, Duration::from_secs(2)), "Sent 1048576 bytes, 0.5 MBs/sec");
1043 assert_eq!(progress("Received", 10, Some(0), Duration::ZERO), "Received 10 bytes, 0.0 MBs/sec");
1044 }
1045
1046 #[test]
The statement is a month at a time, a line per kind of charge1047 fn keys_are_encoded_and_names_checked() {
1048 assert_eq!(urlencode("Linux-node-abc/1 2"), "Linux-node-abc%2F1%202");
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21049 assert!(check_name("coverage report").is_ok());
1050 assert!(check_name("dist-linux_x64.1 (debug)").is_ok());
1051 assert!(check_name("ünïcødé").is_ok());
1052 assert!(check_name(&"a".repeat(256)).is_ok());
1053 assert!(check_name(&"a".repeat(257)).is_err());
1054 assert!(check_name("").is_err());
1055 for bad in ["a/b", "a\\b", "a:b", "a*b", "a?b", "a\"b", "a<b", "a>b", "a|b", "a\nb", "a\rb"] {
1056 assert!(check_name(bad).is_err(), "{bad}");
1057 }
The statement is a month at a time, a line per kind of charge1058 assert_eq!(lines("dist/\n\n# note\n coverage \n"), ["dist/", "coverage"]);
1059 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21060
1061 fn with(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1062 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
1063 }
1064
1065 #[test]
1066 fn upload_inputs_are_read_as_v4_reads_them() {
1067 assert_eq!(keep(&with(&[]), true).unwrap(), Keep { retention: 0, level: 6, overwrite: false, hidden: false });
1068 let set = with(&[("retention-days", "14"), ("compression-level", "0"), ("overwrite", "true"), ("include-hidden-files", "True")]);
1069 assert_eq!(keep(&set, true).unwrap(), Keep { retention: 14, level: 0, overwrite: true, hidden: true });
1070 // The merge takes no `overwrite`.
1071 assert!(!keep(&set, false).unwrap().overwrite);
1072 assert_eq!(keep(&with(&[("retention-days", "0")]), true).unwrap().retention, 0);
1073 assert!(keep(&with(&[("retention-days", "91")]), true).is_err());
1074 assert!(keep(&with(&[("retention-days", "-1")]), true).is_err());
1075 assert!(keep(&with(&[("retention-days", "two")]), true).is_err());
1076 assert!(keep(&with(&[("compression-level", "10")]), true).is_err());
1077 assert!(keep(&with(&[("overwrite", "yes")]), true).is_err());
1078 assert_eq!(artifact_ids(" 12, 34 ,,").unwrap(), [12, 34]);
1079 assert!(artifact_ids("12,abc").is_err());
1080 assert_eq!(count(1, "file"), "1 file");
1081 assert_eq!(count(37, "file"), "37 files");
1082 }
1083
1084 fn listed(id: u64, name: &str) -> Listed {
1085 Listed { id, name: name.into(), size: 0, digest: None, format: "zip".into() }
1086 }
1087
1088 #[test]
1089 fn listings_read_and_keep_the_newest_of_a_name() {
1090 let json = serde_json::json!([
1091 { "id": 1, "name": "dist", "size": 10, "digest": null, "format": "tgz", "created_at": "x", "expires_at": "y" },
1092 { "id": "3", "name": "dist", "size": 30, "digest": "sha256:ab", "format": "zip" },
1093 { "id": 2, "name": "logs", "size": 5 },
1094 { "name": "no id" }
1095 ]);
1096 let all: Vec<Listed> = json.as_array().unwrap().iter().filter_map(Listed::from_json).collect();
1097 assert_eq!(all.len(), 3);
1098 assert_eq!(all[0].format, "tgz");
1099 let kept = newest(all);
1100 assert_eq!(kept.iter().map(|a| (a.id, a.name.as_str())).collect::<Vec<_>>(), [(3, "dist"), (2, "logs")]);
1101 assert_eq!(kept[0].digest.as_deref(), Some("sha256:ab"));
1102 }
1103
1104 #[test]
1105 fn downloads_land_where_v4_puts_them() {
1106 let dest = Path::new("/w/out");
1107 let one = [listed(1, "dist")];
1108 let two = [listed(1, "dist"), listed(2, "logs")];
1109 let at = |targets: Vec<Option<std::path::PathBuf>>| targets.into_iter().map(|t| t.unwrap()).collect::<Vec<_>>();
1110 // By name: straight into `path`.
1111 assert_eq!(at(download_targets(dest, &one, true, false, false)), [dest.to_path_buf()]);
1112 // Every artifact, or by pattern: a folder each.
1113 assert_eq!(at(download_targets(dest, &two, false, false, false)), [dest.join("dist"), dest.join("logs")]);
1114 assert_eq!(at(download_targets(dest, &one, false, false, false)), [dest.join("dist")]);
1115 // merge-multiple: all into `path`.
1116 assert_eq!(at(download_targets(dest, &two, false, false, true)), [dest.to_path_buf(), dest.to_path_buf()]);
1117 // By id: one straight into `path`, several a folder each.
1118 assert_eq!(at(download_targets(dest, &one, false, true, false)), [dest.to_path_buf()]);
1119 assert_eq!(at(download_targets(dest, &two, false, true, false)), [dest.join("dist"), dest.join("logs")]);
1120 // A name that is no folder is refused.
1121 assert!(download_targets(dest, &[listed(1, ".."), listed(2, "x")], false, false, false)[0].is_none());
1122 assert!(glob::matches_part("dist-*", "dist-linux"));
1123 assert!(!glob::matches_part("dist-*", "logs"));
1124 }
The statement is a month at a time, a line per kind of charge1125}

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