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.
| Merge main into the run-protection branch | 1 | //! Artifacts and the cache: `actions/upload-artifact`, its `merge`, |
| A repository has its own sidebar, as settings do | 2 | //! `actions/download-artifact` and `actions/cache`, done natively against |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 3 | //! 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 | //! | |
| Merge main into the run-protection branch | 6 | //! 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 incidents | 10 | //! 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) | |
| Actions: keep workflow runs safe | 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. | |
| A repository has its own sidebar, as settings do | 16 | |
| 17 | use std::collections::BTreeMap; | |
| Merge main into the run-protection branch | 18 | use std::io::{Read, Write}; |
| A repository has its own sidebar, as settings do | 19 | use std::path::Path; |
| 20 | use std::process::Command; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 21 | use std::time::{Duration, Instant}; |
| A repository has its own sidebar, as settings do | 22 | |
| Merge main into the run-protection branch | 23 | use sha2::{Digest, Sha256}; |
| 24 | ||
| A repository has its own sidebar, as settings do | 25 | use super::process::{self, Commands, Ended}; |
| Merge main into the run-protection branch | 26 | use super::{Job, Post, PostRun, glob, zip}; |
| A repository has its own sidebar, as settings do | 27 | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 28 | /// The largest cache entry, compressed (`g1t_contracts::actions::CACHE_MAX_ENTRY_BYTES`). |
| 29 | const MAX_CACHE_ENTRY: u64 = 2 * 1024 * 1024 * 1024; | |
| A repository has its own sidebar, as settings do | 30 | |
| 31 | fn lines(text: &str) -> Vec<String> { | |
| 32 | text.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with('#')).map(str::to_owned).collect() | |
| 33 | } | |
| 34 | ||
| 35 | fn quote(text: &str) -> String { | |
| 36 | format!("'{}'", text.replace('\'', "'\\''")) | |
| 37 | } | |
| 38 | ||
| 39 | impl Job { | |
| 40 | fn url(&self, rest: &str) -> String { | |
| 41 | format!("{}/actions/jobs/{}/{rest}", self.log.api.base, self.log.api.job) | |
| 42 | } | |
| 43 | ||
| 44 | fn auth(&self) -> String { | |
| 45 | format!("Bearer {}", self.log.api.token) | |
| 46 | } | |
| 47 | ||
| 48 | /// Runs a shell line, logging its output; whether it succeeded. | |
| 49 | fn shell(&mut self, script: &str) -> bool { | |
| 50 | let mut command = Command::new("bash"); | |
| 51 | command.args(["-c", script]).current_dir(&self.workspace); | |
| 52 | let mut commands = Commands::default(); | |
| 53 | matches!(process::run(command, Duration::from_secs(1800), &mut self.log, &mut commands), Ok(Ended::Exited(0))) | |
| 54 | } | |
| 55 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 56 | /// Downloads into `file`, as it comes; `Ok(None)` when there is |
| 57 | /// nothing there. | |
| A repository has its own sidebar, as settings do | 58 | fn download(&mut self, rest: &str, file: &Path) -> Result<Option<String>, String> { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 59 | let response = match ureq::get(&self.url(rest)).set("authorization", &self.auth()).timeout(Duration::from_secs(1800)).call() { |
| A repository has its own sidebar, as settings do | 60 | Ok(response) => response, |
| 61 | Err(ureq::Error::Status(404, _)) => return Ok(None), | |
| 62 | Err(error) => return Err(error.to_string()), | |
| 63 | }; | |
| 64 | let matched = response.header("x-g1t-key").map(str::to_owned).unwrap_or_default(); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 65 | let mut out = std::fs::File::create(file).map_err(|e| e.to_string())?; |
| 66 | std::io::copy(&mut response.into_reader().take(MAX_CACHE_ENTRY + 1024), &mut out).map_err(|e| e.to_string())?; | |
| A repository has its own sidebar, as settings do | 67 | Ok(Some(matched)) |
| 68 | } | |
| 69 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 70 | /// Saves `file` as the cache entry `key`, in parts. `Ok(false)` when |
| 71 | /// the key is already cached. | |
| Actions: keep workflow runs safe | 72 | 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 incidents | 73 | let size = std::fs::metadata(file).map_err(|e| e.to_string())?.len(); |
| 74 | if size > MAX_CACHE_ENTRY { | |
| 75 | return Err(format!("it is {} MB, more than a cache entry may be ({} MB)", size / 1_048_576, MAX_CACHE_ENTRY / 1_048_576)); | |
| 76 | } | |
| Actions: keep workflow runs safe | 77 | 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 incidents | 78 | .set("authorization", &self.auth()) |
| 79 | .timeout(Duration::from_secs(60)) | |
| 80 | .call() | |
| 81 | { | |
| 82 | Ok(response) => response.into_json::<serde_json::Value>().map_err(|e| e.to_string())?, | |
| 83 | Err(ureq::Error::Status(409, _)) => return Ok(false), | |
| 84 | Err(ureq::Error::Status(_, response)) => return Err(refusal(response)), | |
| 85 | Err(error) => return Err(error.to_string()), | |
| 86 | }; | |
| 87 | let id = started["id"].as_str().unwrap_or_default().to_owned(); | |
| 88 | let upload = started["upload"].as_str().unwrap_or_default().to_owned(); | |
| 89 | let part_bytes = started["part_bytes"].as_u64().filter(|n| *n > 0).unwrap_or(32 * 1024 * 1024); | |
| 90 | let base = format!("cache/uploads/{}", urlencode(&id)); | |
| 91 | let sent = self.send_parts(&base, &upload, file, part_bytes); | |
| 92 | let parts = match sent { | |
| 93 | Ok(parts) => parts, | |
| 94 | Err(error) => { | |
| 95 | let _ = ureq::delete(&self.url(&format!("{base}?upload={}", urlencode(&upload)))).set("authorization", &self.auth()).call(); | |
| 96 | return Err(error); | |
| 97 | } | |
| 98 | }; | |
| 99 | ureq::post(&self.url(&format!("{base}/complete?upload={}", urlencode(&upload)))) | |
| 100 | .set("authorization", &self.auth()) | |
| 101 | .timeout(Duration::from_secs(120)) | |
| 102 | .send_json(serde_json::json!({ "size": size, "parts": parts })) | |
| 103 | .map_err(|e| match e { | |
| 104 | ureq::Error::Status(_, response) => refusal(response), | |
| 105 | other => other.to_string(), | |
| 106 | })?; | |
| 107 | Ok(true) | |
| 108 | } | |
| 109 | ||
| 110 | /// Sends `file` in parts of `part_bytes`; each part's number and etag. | |
| 111 | fn send_parts(&mut self, base: &str, upload: &str, file: &Path, part_bytes: u64) -> Result<Vec<serde_json::Value>, String> { | |
| 112 | let mut reader = std::fs::File::open(file).map_err(|e| e.to_string())?; | |
| 113 | let mut parts = Vec::new(); | |
| 114 | let mut buffer = vec![0u8; part_bytes as usize]; | |
| 115 | for number in 1u32.. { | |
| 116 | let mut filled = 0; | |
| 117 | while filled < buffer.len() { | |
| 118 | let read = reader.read(&mut buffer[filled..]).map_err(|e| e.to_string())?; | |
| 119 | if read == 0 { | |
| 120 | break; | |
| 121 | } | |
| 122 | filled += read; | |
| 123 | } | |
| 124 | if filled == 0 && number > 1 { | |
| 125 | break; | |
| 126 | } | |
| 127 | let url = self.url(&format!("{base}/{number}?upload={}", urlencode(upload))); | |
| 128 | // A part that fails is sent again, twice at most. | |
| 129 | let mut tries = 0; | |
| 130 | let answer = loop { | |
| 131 | tries += 1; | |
| 132 | match ureq::put(&url).set("authorization", &self.auth()).timeout(Duration::from_secs(600)).send_bytes(&buffer[..filled]) { | |
| 133 | Ok(response) => break response.into_json::<serde_json::Value>().map_err(|e| e.to_string())?, | |
| 134 | Err(ureq::Error::Status(status, response)) if status < 500 => return Err(refusal(response)), | |
| 135 | Err(error) if tries >= 3 => return Err(error.to_string()), | |
| 136 | Err(_) => std::thread::sleep(Duration::from_secs(2 * tries)), | |
| 137 | } | |
| 138 | }; | |
| 139 | parts.push(serde_json::json!({ "part": number, "etag": answer["etag"] })); | |
| 140 | if filled < buffer.len() { | |
| 141 | break; | |
| 142 | } | |
| 143 | } | |
| 144 | Ok(parts) | |
| 145 | } | |
| 146 | ||
| Merge main into the run-protection branch | 147 | /// A value of the `github` context, as text. |
| 148 | fn github_value(&self, key: &str) -> String { | |
| 149 | match self.contexts.get("github").and_then(|github| github.get(key)) { | |
| 150 | Some(serde_json::Value::String(text)) => text.clone(), | |
| 151 | Some(serde_json::Value::Null) | None => String::new(), | |
| 152 | Some(other) => other.to_string(), | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | fn home(&self) -> String { | |
| 157 | self.base_env_value("HOME").unwrap_or_else(|| "/home/node".into()) | |
| 158 | } | |
| 159 | ||
| 160 | /// `actions/upload-artifact`: the files `path` names, packed into one | |
| 161 | /// ZIP and uploaded in parts. | |
| A repository has its own sidebar, as settings do | 162 | pub(crate) fn upload_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { |
| Merge main into the run-protection branch | 163 | let name = input(with, "name").unwrap_or("artifact").to_owned(); |
| 164 | let missing = input(with, "if-no-files-found").unwrap_or("warn").to_ascii_lowercase(); | |
| 165 | let checked = check_name(&name) | |
| 166 | .and_then(|()| keep(with, true)) | |
| 167 | .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.")) }); | |
| 168 | let keep = match checked { | |
| 169 | Ok(keep) => keep, | |
| 170 | Err(message) => { | |
| 171 | self.log.line(&format!("##[error]{message}")); | |
| 172 | return (false, BTreeMap::new()); | |
| A repository has its own sidebar, as settings do | 173 | } |
| Merge main into the run-protection branch | 174 | }; |
| 175 | let paths = lines(input(with, "path").unwrap_or_default()); | |
| 176 | let found = glob::find(&paths, &self.workspace.display().to_string(), &self.home(), keep.hidden); | |
| 177 | if found.files.is_empty() { | |
| 178 | let message = format!("No files were found with the provided path: {}. No artifacts will be uploaded.", paths.join(", ")); | |
| 179 | return match missing.as_str() { | |
| 180 | "error" => { | |
| 181 | self.log.line(&format!("##[error]{message}")); | |
| 182 | (false, BTreeMap::new()) | |
| 183 | } | |
| 184 | "ignore" => { | |
| 185 | self.log.line(&message); | |
| 186 | (true, BTreeMap::new()) | |
| 187 | } | |
| 188 | _ => { | |
| 189 | self.log.line(&format!("##[warning]{message}")); | |
| 190 | (true, BTreeMap::new()) | |
| 191 | } | |
| 192 | }; | |
| 193 | } | |
| 194 | self.log.line(&format!("Found {} with the provided path, stored relative to {}.", count(found.files.len(), "file"), found.root)); | |
| 195 | self.send_artifact(&name, &found.files, &keep) | |
| 196 | } | |
| 197 | ||
| 198 | /// Packs `files` into a ZIP and uploads it as the artifact `name`; | |
| 199 | /// whether it worked, and the outputs `upload-artifact` sets. | |
| 200 | fn send_artifact(&mut self, name: &str, files: &[(String, std::path::PathBuf)], keep: &Keep) -> (bool, BTreeMap<String, String>) { | |
| 201 | let archive = self.temp.join(format!("artifact-{}.zip", super::rand_id())); | |
| 202 | let sent = zip::write(&archive, files, keep.level) | |
| 203 | .map_err(|e| format!("The files could not be packed: {e}")) | |
| 204 | .and_then(|packed| self.post_artifact(name, &archive, keep).map(|done| (packed, done))); | |
| 205 | let _ = std::fs::remove_file(&archive); | |
| 206 | let (packed, done) = match sent { | |
| 207 | Ok(sent) => sent, | |
| 208 | Err(error) => { | |
| 209 | self.log.line(&format!("##[error]{error}")); | |
| A repository has its own sidebar, as settings do | 210 | return (false, BTreeMap::new()); |
| 211 | } | |
| Merge main into the run-protection branch | 212 | }; |
| 213 | let kept = done.retention.map(|days| format!(" It is kept for {}.", count(days as usize, "day"))).unwrap_or_default(); | |
| 214 | self.log.line(&format!("Uploaded artifact {name} ({}, {}).{kept}", megabytes(packed.size), count(packed.files, "file"))); | |
| 215 | let url = format!( | |
| 216 | "{}/{}/actions/runs/{}/artifacts/{}", | |
| 217 | self.github_value("server_url").trim_end_matches('/'), | |
| 218 | self.github_value("repository"), | |
| 219 | self.github_value("run_id"), | |
| 220 | done.id | |
| 221 | ); | |
| 222 | self.log.line(&format!("Artifact download URL: {url}")); | |
| 223 | let mut outputs = BTreeMap::new(); | |
| 224 | outputs.insert("artifact-id".into(), done.id.to_string()); | |
| 225 | outputs.insert("artifact-url".into(), url); | |
| 226 | outputs.insert("artifact-digest".into(), done.digest); | |
| 227 | (true, outputs) | |
| 228 | } | |
| 229 | ||
| 230 | /// Uploads the ZIP file `archive` as the artifact `name`, in parts, | |
| 231 | /// giving the upload up when a part or the end fails. | |
| 232 | fn post_artifact(&mut self, name: &str, archive: &Path, keep: &Keep) -> Result<Sent, String> { | |
| 233 | let size = std::fs::metadata(archive).map_err(|e| e.to_string())?.len(); | |
| 234 | let digest = sha256_file(archive).map_err(|e| e.to_string())?; | |
| 235 | let query = format!( | |
| 236 | "artifacts/uploads?name={}&size={size}&retention_days={}&overwrite={}&format=zip", | |
| 237 | urlencode(name), | |
| 238 | keep.retention, | |
| 239 | keep.overwrite | |
| 240 | ); | |
| 241 | let failed = |e: ureq::Error| match e { | |
| 242 | ureq::Error::Status(_, response) => format!("The artifact could not be uploaded: {}", refusal(response)), | |
| 243 | other => format!("The artifact could not be uploaded: {other}"), | |
| 244 | }; | |
| 245 | let started = ureq::post(&self.url(&query)) | |
| 246 | .set("authorization", &self.auth()) | |
| 247 | .timeout(Duration::from_secs(60)) | |
| 248 | .call() | |
| 249 | .map_err(failed)? | |
| 250 | .into_json::<serde_json::Value>() | |
| 251 | .map_err(|e| e.to_string())?; | |
| 252 | let id = number(&started["id"]).ok_or("g1t did not say which artifact the upload is")?; | |
| 253 | let upload = started["upload"].as_str().unwrap_or_default().to_owned(); | |
| 254 | let part_bytes = started["part_bytes"].as_u64().filter(|n| *n > 0).unwrap_or(32 * 1024 * 1024); | |
| 255 | let retention = started["retention_days"].as_u64(); | |
| 256 | if let Some(applied) = retention | |
| 257 | && keep.retention != 0 | |
| 258 | && applied != u64::from(keep.retention) | |
| 259 | { | |
| 260 | self.log.line(&format!( | |
| 261 | "##[notice]The artifact is kept for {}, not the {} asked for: the most this repository keeps artifacts.", | |
| 262 | count(applied as usize, "day"), | |
| 263 | keep.retention | |
| 264 | )); | |
| A repository has its own sidebar, as settings do | 265 | } |
| Merge main into the run-protection branch | 266 | let base = format!("artifacts/uploads/{id}"); |
| 267 | let give_up = |job: &Job| { | |
| 268 | let _ = ureq::delete(&job.url(&format!("{base}?upload={}", urlencode(&upload)))).set("authorization", &job.auth()).call(); | |
| 269 | }; | |
| 270 | let parts = match self.send_parts(&base, &upload, archive, part_bytes) { | |
| 271 | Ok(parts) => parts, | |
| 272 | Err(error) => { | |
| 273 | give_up(self); | |
| 274 | return Err(format!("The artifact could not be uploaded: {error}")); | |
| A repository has its own sidebar, as settings do | 275 | } |
| Merge main into the run-protection branch | 276 | }; |
| 277 | let done = ureq::post(&self.url(&format!("{base}/complete?upload={}", urlencode(&upload)))) | |
| 278 | .set("authorization", &self.auth()) | |
| 279 | .timeout(Duration::from_secs(120)) | |
| 280 | .send_json(serde_json::json!({ "size": size, "parts": parts, "digest": format!("sha256:{digest}") })); | |
| 281 | let done = match done { | |
| 282 | Ok(response) => response.into_json::<serde_json::Value>().unwrap_or_default(), | |
| A repository has its own sidebar, as settings do | 283 | Err(error) => { |
| Merge main into the run-protection branch | 284 | give_up(self); |
| 285 | return Err(failed(error)); | |
| 286 | } | |
| 287 | }; | |
| 288 | 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)) }) | |
| 289 | } | |
| 290 | ||
| 291 | /// The artifacts of this run, or of the run `run_id` of this | |
| 292 | /// repository; one per name, the newest. | |
| 293 | fn list_artifacts(&mut self, run_id: Option<&str>) -> Result<Vec<Listed>, String> { | |
| 294 | let rest = match run_id { | |
| 295 | Some(run) => format!("artifacts?run_id={}", urlencode(run)), | |
| 296 | None => "artifacts".to_owned(), | |
| 297 | }; | |
| 298 | let listed = ureq::get(&self.url(&rest)) | |
| 299 | .set("authorization", &self.auth()) | |
| 300 | .timeout(Duration::from_secs(60)) | |
| 301 | .call() | |
| 302 | .map_err(|e| match e { | |
| 303 | ureq::Error::Status(_, response) => refusal(response), | |
| 304 | other => other.to_string(), | |
| 305 | })? | |
| 306 | .into_json::<Vec<serde_json::Value>>() | |
| 307 | .map_err(|e| e.to_string())?; | |
| 308 | Ok(newest(listed.iter().filter_map(Listed::from_json).collect())) | |
| 309 | } | |
| 310 | ||
| 311 | /// Downloads an artifact to a file, as it comes, and unpacks it into | |
| 312 | /// `into`. | |
| 313 | fn fetch_artifact(&mut self, artifact: &Listed, into: &Path) -> Result<(), String> { | |
| 314 | let file = self.temp.join(format!("download-{}.zip", super::rand_id())); | |
| 315 | let fetched = self.fetch_to(artifact, &file).and_then(|format| { | |
| 316 | if format == "tgz" { | |
| 317 | std::fs::create_dir_all(into).map_err(|e| e.to_string())?; | |
| 318 | let script = format!("tar -xzf {} -C {}", quote(&file.display().to_string()), quote(&into.display().to_string())); | |
| 319 | if self.shell(&script) { Ok(()) } else { Err("it could not be unpacked".into()) } | |
| 320 | } else { | |
| 321 | zip::extract(&file, into).map(|_| ()).map_err(|e| format!("it could not be unpacked: {e}")) | |
| 322 | } | |
| 323 | }); | |
| 324 | let _ = std::fs::remove_file(&file); | |
| 325 | fetched | |
| 326 | } | |
| 327 | ||
| 328 | /// Downloads an artifact into `file`, checking its digest; how it is | |
| 329 | /// packed, `zip` or `tgz`. | |
| 330 | fn fetch_to(&mut self, artifact: &Listed, file: &Path) -> Result<String, String> { | |
| 331 | let response = match ureq::get(&self.url(&format!("artifacts/{}/download", artifact.id))) | |
| 332 | .set("authorization", &self.auth()) | |
| 333 | .timeout(Duration::from_secs(4 * 3600)) | |
| 334 | .call() | |
| 335 | { | |
| 336 | Ok(response) => response, | |
| 337 | Err(ureq::Error::Status(404, _)) => return Err("it is gone: it expired or was deleted".into()), | |
| 338 | Err(ureq::Error::Status(_, response)) => return Err(refusal(response)), | |
| 339 | Err(error) => return Err(error.to_string()), | |
| 340 | }; | |
| 341 | let format = response.header("x-g1t-format").map(str::to_owned).unwrap_or_else(|| artifact.format.clone()); | |
| 342 | let mut reader = response.into_reader(); | |
| 343 | let mut out = std::io::BufWriter::new(std::fs::File::create(file).map_err(|e| e.to_string())?); | |
| 344 | let mut hasher = Sha256::new(); | |
| 345 | let mut buffer = vec![0u8; 256 * 1024]; | |
| 346 | loop { | |
| 347 | let n = reader.read(&mut buffer).map_err(|e| e.to_string())?; | |
| 348 | if n == 0 { | |
| 349 | break; | |
| A repository has its own sidebar, as settings do | 350 | } |
| Merge main into the run-protection branch | 351 | hasher.update(&buffer[..n]); |
| 352 | out.write_all(&buffer[..n]).map_err(|e| e.to_string())?; | |
| A repository has its own sidebar, as settings do | 353 | } |
| Merge main into the run-protection branch | 354 | out.flush().map_err(|e| e.to_string())?; |
| 355 | let got = hex::encode(hasher.finalize()); | |
| 356 | if let Some(expected) = artifact.digest.as_deref().map(|d| d.trim_start_matches("sha256:")) | |
| 357 | && !expected.is_empty() | |
| 358 | && !expected.eq_ignore_ascii_case(&got) | |
| 359 | { | |
| 360 | 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)); | |
| 361 | } | |
| 362 | Ok(format) | |
| A repository has its own sidebar, as settings do | 363 | } |
| 364 | ||
| Merge main into the run-protection branch | 365 | /// Where artifacts are downloaded: `path`, under the workspace unless |
| 366 | /// absolute, `~` the home folder. | |
| 367 | fn download_dir(&self, path: Option<&str>) -> std::path::PathBuf { | |
| 368 | match path { | |
| 369 | None => self.workspace.clone(), | |
| 370 | Some(path) => { | |
| 371 | let home = self.home(); | |
| 372 | let path = if path == "~" { | |
| 373 | home | |
| 374 | } else if let Some(rest) = path.strip_prefix("~/") { | |
| 375 | format!("{}/{rest}", home.trim_end_matches('/')) | |
| 376 | } else { | |
| 377 | path.to_owned() | |
| 378 | }; | |
| 379 | self.workspace.join(path) | |
| A repository has its own sidebar, as settings do | 380 | } |
| Merge main into the run-protection branch | 381 | } |
| 382 | } | |
| 383 | ||
| 384 | /// `actions/download-artifact`: one artifact by name straight into | |
| 385 | /// `path`, or the run's artifacts (by `pattern` or `artifact-ids`) each | |
| 386 | /// into a folder of its name, or all into `path` with `merge-multiple`. | |
| 387 | pub(crate) fn download_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 388 | let fail = |job: &mut Job, message: &str| { | |
| 389 | job.log.line(&format!("##[error]{message}")); | |
| 390 | (false, BTreeMap::new()) | |
| 391 | }; | |
| 392 | let name = input(with, "name"); | |
| 393 | let ids = match artifact_ids(input(with, "artifact-ids").unwrap_or_default()) { | |
| 394 | Ok(ids) => ids, | |
| 395 | Err(message) => return fail(self, &message), | |
| 396 | }; | |
| 397 | if name.is_some() && !ids.is_empty() { | |
| 398 | return fail(self, "Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one."); | |
| 399 | } | |
| 400 | let merge = match flag(with, "merge-multiple", false) { | |
| 401 | Ok(merge) => merge, | |
| 402 | Err(message) => return fail(self, &message), | |
| A repository has its own sidebar, as settings do | 403 | }; |
| Merge main into the run-protection branch | 404 | let dest = self.download_dir(input(with, "path")); |
| 405 | // Another run's artifacts, as v4 has it: with a token and a run id. | |
| 406 | let run_id = match (input(with, "github-token"), input(with, "run-id")) { | |
| 407 | (Some(_), Some(run)) => { | |
| 408 | let own = self.github_value("repository"); | |
| 409 | let repository = input(with, "repository").unwrap_or(&own); | |
| 410 | if !repository.eq_ignore_ascii_case(&own) { | |
| 411 | return fail(self, &format!("g1t downloads artifacts from other runs of the same repository only; {repository} is not this run's repository, {own}.")); | |
| A repository has its own sidebar, as settings do | 412 | } |
| Merge main into the run-protection branch | 413 | Some(run.to_owned()) |
| A repository has its own sidebar, as settings do | 414 | } |
| Merge main into the run-protection branch | 415 | _ => None, |
| 416 | }; | |
| 417 | let listed = match self.list_artifacts(run_id.as_deref()) { | |
| 418 | Ok(listed) => listed, | |
| 419 | Err(error) => return fail(self, &format!("The artifacts could not be listed: {error}")), | |
| 420 | }; | |
| 421 | let chosen: Vec<Listed> = if let Some(name) = name { | |
| 422 | match listed.into_iter().find(|a| a.name == name) { | |
| 423 | Some(artifact) => vec![artifact], | |
| 424 | None => return fail(self, &format!("Unable to download artifact(s): Artifact not found for name: {name}")), | |
| A repository has its own sidebar, as settings do | 425 | } |
| Merge main into the run-protection branch | 426 | } else if !ids.is_empty() { |
| 427 | let chosen: Vec<Listed> = listed.into_iter().filter(|a| ids.contains(&a.id)).collect(); | |
| 428 | if chosen.is_empty() { | |
| 429 | return fail(self, "Unable to download artifact(s): None of the provided artifact IDs were found."); | |
| 430 | } | |
| 431 | if chosen.len() < ids.len() { | |
| 432 | self.log.line(&format!("##[warning]Could only find {} of {} artifact IDs.", chosen.len(), ids.len())); | |
| 433 | } | |
| 434 | chosen | |
| 435 | } else { | |
| 436 | match input(with, "pattern") { | |
| 437 | Some(pattern) => listed.into_iter().filter(|a| glob::matches_part(pattern, &a.name)).collect(), | |
| 438 | None => listed, | |
| 439 | } | |
| 440 | }; | |
| A repository has its own sidebar, as settings do | 441 | let mut outputs = BTreeMap::new(); |
| 442 | outputs.insert("download-path".into(), dest.display().to_string()); | |
| Merge main into the run-protection branch | 443 | if chosen.is_empty() { |
| 444 | match input(with, "pattern") { | |
| 445 | Some(pattern) => self.log.line(&format!("No artifacts matched the pattern {pattern}; nothing was downloaded.")), | |
| 446 | None => self.log.line("The run has no artifacts; nothing was downloaded."), | |
| 447 | } | |
| 448 | return (true, outputs); | |
| 449 | } | |
| 450 | let targets = download_targets(&dest, &chosen, name.is_some(), !ids.is_empty(), merge); | |
| 451 | for (artifact, target) in chosen.iter().zip(targets) { | |
| 452 | let Some(target) = target else { | |
| 453 | return fail(self, &format!("The artifact {} cannot be downloaded into a folder of its name.", artifact.name)); | |
| 454 | }; | |
| 455 | if let Err(error) = self.fetch_artifact(artifact, &target) { | |
| 456 | return fail(self, &format!("The artifact {} could not be downloaded: {error}", artifact.name)); | |
| 457 | } | |
| 458 | self.log.line(&format!("Downloaded artifact {} ({}) into {}", artifact.name, megabytes(artifact.size), target.display())); | |
| 459 | } | |
| 460 | if chosen.len() > 1 { | |
| 461 | self.log.line(&format!("Downloaded {}.", count(chosen.len(), "artifact"))); | |
| 462 | } | |
| A repository has its own sidebar, as settings do | 463 | (true, outputs) |
| 464 | } | |
| 465 | ||
| Merge main into the run-protection branch | 466 | /// `actions/upload-artifact/merge`: the run's artifacts matching |
| 467 | /// `pattern`, downloaded together and uploaded again as one. | |
| 468 | pub(crate) fn merge_artifacts(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 469 | let fail = |job: &mut Job, message: &str| { | |
| 470 | job.log.line(&format!("##[error]{message}")); | |
| 471 | (false, BTreeMap::new()) | |
| 472 | }; | |
| 473 | let name = input(with, "name").unwrap_or("merged-artifacts").to_owned(); | |
| 474 | let pattern = input(with, "pattern").unwrap_or("*").to_owned(); | |
| 475 | let options = check_name(&name).and_then(|()| { | |
| 476 | let keep = keep(with, false)?; | |
| 477 | Ok((keep, flag(with, "separate-directories", false)?, flag(with, "delete-merged", false)?)) | |
| 478 | }); | |
| 479 | let (keep, separate, delete) = match options { | |
| 480 | Ok(options) => options, | |
| 481 | Err(message) => return fail(self, &message), | |
| 482 | }; | |
| 483 | let listed = match self.list_artifacts(None) { | |
| 484 | Ok(listed) => listed, | |
| 485 | Err(error) => return fail(self, &format!("The artifacts could not be listed: {error}")), | |
| 486 | }; | |
| 487 | let chosen: Vec<Listed> = listed.into_iter().filter(|a| glob::matches_part(&pattern, &a.name)).collect(); | |
| 488 | if chosen.is_empty() { | |
| 489 | return fail(self, &format!("No artifacts found matching pattern '{pattern}'.")); | |
| 490 | } | |
| 491 | self.log.line(&format!("Merging {}: {}", count(chosen.len(), "artifact"), chosen.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", "))); | |
| 492 | let folder = self.temp.join(format!("merge-{}", super::rand_id())); | |
| 493 | let targets = download_targets(&folder, &chosen, false, false, !separate); | |
| 494 | let mut result = None; | |
| 495 | for (artifact, target) in chosen.iter().zip(targets) { | |
| 496 | let Some(target) = target else { | |
| 497 | result = Some(fail(self, &format!("The artifact {} cannot be merged into a folder of its name.", artifact.name))); | |
| 498 | break; | |
| 499 | }; | |
| 500 | if let Err(error) = self.fetch_artifact(artifact, &target) { | |
| 501 | result = Some(fail(self, &format!("The artifact {} could not be downloaded: {error}", artifact.name))); | |
| 502 | break; | |
| 503 | } | |
| 504 | } | |
| 505 | let result = result.unwrap_or_else(|| { | |
| 506 | let root = folder.display().to_string(); | |
| 507 | let found = glob::find(std::slice::from_ref(&root), &root, &self.home(), keep.hidden); | |
| 508 | if found.files.is_empty() { | |
| 509 | fail(self, "The artifacts matched hold no files to merge.") | |
| 510 | } else { | |
| 511 | self.send_artifact(&name, &found.files, &keep) | |
| 512 | } | |
| 513 | }); | |
| 514 | let _ = std::fs::remove_dir_all(&folder); | |
| 515 | if result.0 && delete { | |
| 516 | for artifact in &chosen { | |
| 517 | match ureq::delete(&self.url(&format!("artifacts/{}", artifact.id))).set("authorization", &self.auth()).timeout(Duration::from_secs(60)).call() { | |
| 518 | Ok(_) => self.log.line(&format!("Deleted artifact {}.", artifact.name)), | |
| 519 | Err(ureq::Error::Status(_, response)) => self.log.line(&format!("##[warning]Artifact {} could not be deleted: {}", artifact.name, refusal(response))), | |
| 520 | Err(error) => self.log.line(&format!("##[warning]Artifact {} could not be deleted: {error}", artifact.name)), | |
| 521 | } | |
| 522 | } | |
| 523 | } | |
| 524 | result | |
| 525 | } | |
| 526 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 527 | /// The cache's `path`, each made absolute (`~/` is the home folder, |
| 528 | /// anything else is under the workspace), `!` patterns kept as they | |
| 529 | /// came, with their `!`. | |
| A repository has its own sidebar, as settings do | 530 | fn cache_paths(&self, with: &BTreeMap<String, String>) -> Vec<String> { |
| 531 | let home = self.base_env_value("HOME").unwrap_or_else(|| "/home/node".into()); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 532 | let workspace = self.workspace.display().to_string(); |
| 533 | cache_patterns(with.get("path").map(String::as_str).unwrap_or_default(), &home, &workspace) | |
| A repository has its own sidebar, as settings do | 534 | } |
| 535 | ||
| 536 | /// `actions/cache` and `actions/cache/restore`: restores what it can, | |
| 537 | /// and for `actions/cache`, saves at the end of the job on a miss. | |
| 538 | pub(crate) fn cache(&mut self, with: &BTreeMap<String, String>, save_after: bool, title: &str) -> (bool, BTreeMap<String, String>) { | |
| 539 | let key = with.get("key").cloned().unwrap_or_default(); | |
| 540 | if key.is_empty() { | |
| 541 | self.log.line("##[error]The cache needs a `key`."); | |
| 542 | return (false, BTreeMap::new()); | |
| 543 | } | |
| 544 | let paths = self.cache_paths(with); | |
| Actions: keep workflow runs safe | 545 | let version = cache_version(with.get("path").map(String::as_str).unwrap_or_default(), compression()); |
| A repository has its own sidebar, as settings do | 546 | let restore = lines(with.get("restore-keys").map(String::as_str).unwrap_or_default()); |
| 547 | let query = format!( | |
| Actions: keep workflow runs safe | 548 | "cache?key={}&restore={}&version={}", |
| A repository has its own sidebar, as settings do | 549 | urlencode(&key), |
| Actions: keep workflow runs safe | 550 | urlencode(&restore.join("\n")), |
| 551 | urlencode(&version) | |
| A repository has its own sidebar, as settings do | 552 | ); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 553 | let archive = self.temp.join("cache-restore.tar"); |
| 554 | let started = Instant::now(); | |
| A repository has its own sidebar, as settings do | 555 | let mut outputs = BTreeMap::new(); |
| 556 | let exact = match self.download(&query, &archive) { | |
| 557 | Ok(Some(matched)) => { | |
| 558 | let lookup_only = with.get("lookup-only").is_some_and(|v| v == "true"); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 559 | let size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0); |
| 560 | // tar finds out from the archive whether it is zstd or gzip. | |
| 561 | if !lookup_only && !self.shell(&format!("tar -xPf {}", quote(&archive.display().to_string()))) { | |
| A repository has its own sidebar, as settings do | 562 | self.log.line("##[warning]The cache was found but could not be unpacked."); |
| 563 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 564 | let _ = std::fs::remove_file(&archive); |
| 565 | self.log.line(&format!("Cache restored from key: {matched} ({} in {:.1}s)", megabytes(size), started.elapsed().as_secs_f64())); | |
| A repository has its own sidebar, as settings do | 566 | outputs.insert("cache-matched-key".into(), matched.clone()); |
| 567 | matched == key | |
| 568 | } | |
| 569 | Ok(None) => { | |
| 570 | self.log.line(&format!("Cache not found for input keys: {}", std::iter::once(key.clone()).chain(restore).collect::<Vec<_>>().join(", "))); | |
| 571 | if with.get("fail-on-cache-miss").is_some_and(|v| v == "true") { | |
| 572 | self.log.line("##[error]The cache missed, and `fail-on-cache-miss` is set."); | |
| 573 | return (false, outputs); | |
| 574 | } | |
| 575 | false | |
| 576 | } | |
| 577 | Err(error) => { | |
| 578 | self.log.line(&format!("##[warning]The cache could not be read: {error}")); | |
| 579 | false | |
| 580 | } | |
| 581 | }; | |
| 582 | outputs.insert("cache-hit".into(), exact.to_string()); | |
| 583 | outputs.insert("cache-primary-key".into(), key.clone()); | |
| 584 | if save_after && !exact { | |
| 585 | self.posts.push(Post { | |
| 586 | name: format!("Post {title}"), | |
| 587 | condition: "success()".into(), | |
| 588 | env: BTreeMap::new(), | |
| Actions: keep workflow runs safe | 589 | run: PostRun::CacheSave { key, paths, version }, |
| A repository has its own sidebar, as settings do | 590 | }); |
| 591 | } | |
| 592 | (true, outputs) | |
| 593 | } | |
| 594 | ||
| 595 | /// Saves paths under a key, unless the key is taken. | |
| Actions: keep workflow runs safe | 596 | 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 incidents | 597 | if paths.iter().all(|p| p.starts_with('!')) { |
| A repository has its own sidebar, as settings do | 598 | self.log.line("##[warning]Nothing to cache: no `path`."); |
| 599 | return true; | |
| 600 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 601 | let archive = self.temp.join("cache-save.tar"); |
| 602 | let started = Instant::now(); | |
| 603 | match self.run_shell(&pack_script(paths, &archive.display().to_string())) { | |
| 604 | Some(0) => {} | |
| 605 | Some(3) => { | |
| 606 | self.log.line("##[warning]None of the cache's paths exist; nothing was saved."); | |
| 607 | return true; | |
| 608 | } | |
| 609 | _ => { | |
| 610 | self.log.line("##[warning]The cache could not be packed; nothing was saved."); | |
| 611 | return true; | |
| 612 | } | |
| A repository has its own sidebar, as settings do | 613 | } |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 614 | let size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0); |
| 615 | let packed = started.elapsed().as_secs_f64(); | |
| Actions: keep workflow runs safe | 616 | match self.upload_cache(key, version, &archive) { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 617 | Ok(true) => self.log.line(&format!( |
| 618 | "Cache saved with key: {key} ({}, packed in {packed:.1}s, sent in {:.1}s)", | |
| 619 | megabytes(size), | |
| 620 | started.elapsed().as_secs_f64() - packed | |
| 621 | )), | |
| 622 | Ok(false) => self.log.line(&format!("Cache not saved: {key} is already cached.")), | |
| A repository has its own sidebar, as settings do | 623 | // A cache that cannot be saved does not fail the job, as on GitHub. |
| 624 | Err(error) => self.log.line(&format!("##[warning]The cache could not be saved: {error}")), | |
| 625 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 626 | let _ = std::fs::remove_file(&archive); |
| A repository has its own sidebar, as settings do | 627 | true |
| 628 | } | |
| 629 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 630 | /// Runs a shell line, logging its output; its exit code. |
| 631 | fn run_shell(&mut self, script: &str) -> Option<i32> { | |
| 632 | let mut command = Command::new("bash"); | |
| 633 | command.args(["-c", script]).current_dir(&self.workspace); | |
| 634 | let mut commands = Commands::default(); | |
| 635 | match process::run(command, Duration::from_secs(1800), &mut self.log, &mut commands) { | |
| 636 | Ok(Ended::Exited(code)) => Some(code), | |
| 637 | _ => None, | |
| 638 | } | |
| 639 | } | |
| 640 | ||
| A repository has its own sidebar, as settings do | 641 | /// `actions/cache/save`. |
| 642 | pub(crate) fn cache_save_now(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 643 | let key = with.get("key").cloned().unwrap_or_default(); | |
| 644 | let paths = self.cache_paths(with); | |
| Actions: keep workflow runs safe | 645 | let version = cache_version(with.get("path").map(String::as_str).unwrap_or_default(), compression()); |
| 646 | (self.cache_save(&key, &paths, &version), BTreeMap::new()) | |
| A repository has its own sidebar, as settings do | 647 | } |
| 648 | } | |
| 649 | ||
| Merge main into the run-protection branch | 650 | /// An input, trimmed; `None` when empty. |
| 651 | fn input<'a>(with: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> { | |
| 652 | with.get(key).map(|v| v.trim()).filter(|v| !v.is_empty()) | |
| 653 | } | |
| 654 | ||
| 655 | /// A true-or-false input, as `core.getBooleanInput` reads it. | |
| 656 | fn flag(with: &BTreeMap<String, String>, key: &str, default: bool) -> Result<bool, String> { | |
| 657 | match input(with, key) { | |
| 658 | None => Ok(default), | |
| 659 | Some("true" | "True" | "TRUE") => Ok(true), | |
| 660 | Some("false" | "False" | "FALSE") => Ok(false), | |
| 661 | Some(other) => Err(format!("`{key}` is `{other}`; it takes true or false.")), | |
| 662 | } | |
| 663 | } | |
| 664 | ||
| 665 | /// `1 file`, `3 files`. | |
| 666 | fn count(n: usize, what: &str) -> String { | |
| 667 | if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") } | |
| 668 | } | |
| 669 | ||
| 670 | /// An id that came as a number or as text. | |
| 671 | fn number(value: &serde_json::Value) -> Option<u64> { | |
| 672 | value.as_u64().or_else(|| value.as_str().and_then(|s| s.parse().ok())) | |
| 673 | } | |
| 674 | ||
| 675 | /// Whether a name is one GitHub takes for an artifact: not empty, at most | |
| 676 | /// 256 characters, none of `" : < > | * ? \ /` or a line break. | |
| 677 | fn check_name(name: &str) -> Result<(), String> { | |
| 678 | if name.is_empty() { | |
| 679 | return Err("The artifact needs a name.".into()); | |
| 680 | } | |
| 681 | if name.chars().count() > 256 { | |
| 682 | return Err(format!("The artifact name `{name}` is longer than 256 characters.")); | |
| 683 | } | |
| 684 | if let Some(bad) = name.chars().find(|c| matches!(c, '"' | ':' | '<' | '>' | '|' | '*' | '?' | '\r' | '\n' | '\\' | '/')) { | |
| 685 | let shown = match bad { | |
| 686 | '\r' => "a carriage return".to_owned(), | |
| 687 | '\n' => "a line break".to_owned(), | |
| 688 | c => format!("`{c}`"), | |
| 689 | }; | |
| 690 | return Err(format!("The artifact name `{name}` is not valid: it contains {shown}. A name may not contain \" : < > | * ? \\ / or line breaks.")); | |
| 691 | } | |
| 692 | Ok(()) | |
| 693 | } | |
| 694 | ||
| 695 | /// How an artifact is packed and kept, from the inputs `upload-artifact` | |
| 696 | /// and its merge share. | |
| 697 | #[derive(Debug, PartialEq)] | |
| 698 | struct Keep { | |
| 699 | /// Days; 0 for the repository's own setting. | |
| 700 | retention: u32, | |
| 701 | /// 0 (stored) to 9. | |
| 702 | level: u32, | |
| 703 | overwrite: bool, | |
| 704 | /// Whether files and folders whose names start with `.` are taken. | |
| 705 | hidden: bool, | |
| 706 | } | |
| 707 | ||
| 708 | fn keep(with: &BTreeMap<String, String>, takes_overwrite: bool) -> Result<Keep, String> { | |
| 709 | let retention = match input(with, "retention-days") { | |
| 710 | None => 0, | |
| 711 | Some(text) => match text.parse::<u32>() { | |
| 712 | Ok(days @ 0..=90) => days, | |
| 713 | _ => return Err(format!("`retention-days` is `{text}`; it takes a number of days from 1 to 90, or nothing for the repository's setting.")), | |
| 714 | }, | |
| 715 | }; | |
| 716 | let level = match input(with, "compression-level") { | |
| 717 | None => 6, | |
| 718 | Some(text) => match text.parse::<u32>() { | |
| 719 | Ok(level @ 0..=9) => level, | |
| 720 | _ => return Err(format!("`compression-level` is `{text}`; it takes 0 (no compression) to 9.")), | |
| 721 | }, | |
| 722 | }; | |
| 723 | Ok(Keep { | |
| 724 | retention, | |
| 725 | level, | |
| 726 | overwrite: takes_overwrite && flag(with, "overwrite", false)?, | |
| 727 | hidden: flag(with, "include-hidden-files", false)?, | |
| 728 | }) | |
| 729 | } | |
| 730 | ||
| 731 | /// What finishing an upload gave: the artifact's id, the ZIP's SHA-256 in | |
| 732 | /// hex, and the days it is kept, when known. | |
| 733 | struct Sent { | |
| 734 | id: u64, | |
| 735 | digest: String, | |
| 736 | retention: Option<u32>, | |
| 737 | } | |
| 738 | ||
| 739 | /// An artifact as g1t lists it. | |
| 740 | #[derive(Debug, Clone, PartialEq)] | |
| 741 | struct Listed { | |
| 742 | id: u64, | |
| 743 | name: String, | |
| 744 | size: u64, | |
| 745 | digest: Option<String>, | |
| 746 | /// `zip`, or `tgz` for an artifact an older runner stored. | |
| 747 | format: String, | |
| 748 | } | |
| 749 | ||
| 750 | impl Listed { | |
| 751 | fn from_json(value: &serde_json::Value) -> Option<Listed> { | |
| 752 | Some(Listed { | |
| 753 | id: number(&value["id"])?, | |
| 754 | name: value["name"].as_str()?.to_owned(), | |
| 755 | size: value["size"].as_u64().unwrap_or(0), | |
| 756 | digest: value["digest"].as_str().map(str::to_owned), | |
| 757 | format: value["format"].as_str().unwrap_or("zip").to_owned(), | |
| 758 | }) | |
| 759 | } | |
| 760 | } | |
| 761 | ||
| 762 | /// One artifact per name, the newest (highest id), in name order. | |
| 763 | fn newest(listed: Vec<Listed>) -> Vec<Listed> { | |
| 764 | let mut by_name: BTreeMap<String, Listed> = BTreeMap::new(); | |
| 765 | for artifact in listed { | |
| 766 | if by_name.get(&artifact.name).is_none_or(|kept| kept.id < artifact.id) { | |
| 767 | by_name.insert(artifact.name.clone(), artifact); | |
| 768 | } | |
| 769 | } | |
| 770 | by_name.into_values().collect() | |
| 771 | } | |
| 772 | ||
| 773 | /// `artifact-ids`: numbers, separated by commas. | |
| 774 | fn artifact_ids(text: &str) -> Result<Vec<u64>, String> { | |
| 775 | text.split(',') | |
| 776 | .map(str::trim) | |
| 777 | .filter(|id| !id.is_empty()) | |
| 778 | .map(|id| id.parse::<u64>().map_err(|_| format!("`artifact-ids` takes artifact ids, numbers separated by commas; `{id}` is not one."))) | |
| 779 | .collect() | |
| 780 | } | |
| 781 | ||
| 782 | /// The folder each artifact is unpacked into: `dest` itself for one | |
| 783 | /// artifact by name, for `merge-multiple`, and for a single artifact by | |
| 784 | /// id; otherwise a folder of the artifact's name in `dest`. `None` for a | |
| 785 | /// name that cannot be a folder. | |
| 786 | fn download_targets(dest: &Path, chosen: &[Listed], by_name: bool, by_ids: bool, merge: bool) -> Vec<Option<std::path::PathBuf>> { | |
| 787 | let straight = by_name || merge || (by_ids && chosen.len() == 1); | |
| 788 | chosen | |
| 789 | .iter() | |
| 790 | .map(|artifact| { | |
| 791 | if straight { | |
| 792 | Some(dest.to_path_buf()) | |
| 793 | } else if artifact.name.is_empty() || artifact.name == "." || artifact.name == ".." || artifact.name.contains(['/', '\\']) { | |
| 794 | None | |
| 795 | } else { | |
| 796 | Some(dest.join(&artifact.name)) | |
| 797 | } | |
| 798 | }) | |
| 799 | .collect() | |
| 800 | } | |
| 801 | ||
| 802 | /// The SHA-256 of a file, in hex, read as it comes. | |
| 803 | fn sha256_file(path: &Path) -> std::io::Result<String> { | |
| 804 | let mut file = std::fs::File::open(path)?; | |
| 805 | let mut hasher = Sha256::new(); | |
| 806 | let mut buffer = vec![0u8; 256 * 1024]; | |
| 807 | loop { | |
| 808 | let n = file.read(&mut buffer)?; | |
| 809 | if n == 0 { | |
| 810 | break; | |
| 811 | } | |
| 812 | hasher.update(&buffer[..n]); | |
| 813 | } | |
| 814 | Ok(hex::encode(hasher.finalize())) | |
| 815 | } | |
| 816 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 817 | /// The message of a refused request, from its JSON body. |
| 818 | fn refusal(response: ureq::Response) -> String { | |
| 819 | let status = response.status(); | |
| 820 | let body: serde_json::Value = response.into_json().unwrap_or_default(); | |
| 821 | body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {status}"), str::to_owned) | |
| 822 | } | |
| 823 | ||
| 824 | fn megabytes(bytes: u64) -> String { | |
| 825 | if bytes < 1_048_576 { format!("{} KB", bytes.div_ceil(1024)) } else { format!("{:.1} MB", bytes as f64 / 1_048_576.0) } | |
| 826 | } | |
| 827 | ||
| Actions: keep workflow runs safe | 828 | /// How this machine compresses cache entries: zstd where it has it, else |
| 829 | /// gzip, as `pack_script` decides. | |
| 830 | fn compression() -> &'static str { | |
| 831 | let zstd = Command::new("sh").args(["-c", "command -v zstd"]).output().is_ok_and(|out| out.status.success()); | |
| 832 | if zstd { "zstd" } else { "gzip" } | |
| 833 | } | |
| 834 | ||
| 835 | /// An entry's version: the hash of its `path` lines, as written, and its | |
| 836 | /// compression, as `actions/cache` makes one. The same key saved for other | |
| 837 | /// paths, or packed another way, is another entry. | |
| 838 | fn cache_version(path: &str, compression: &str) -> String { | |
| 839 | use sha2::{Digest, Sha256}; | |
| 840 | let mut parts = lines(path); | |
| 841 | parts.push(compression.to_owned()); | |
| 842 | hex::encode(Sha256::digest(parts.join("|").as_bytes())) | |
| 843 | } | |
| 844 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 845 | /// The lines of a cache's `path`, absolute: `~/` is `home`, a relative |
| 846 | /// path is under `workspace`. A `!` pattern keeps its `!`. | |
| 847 | fn cache_patterns(path: &str, home: &str, workspace: &str) -> Vec<String> { | |
| 848 | lines(path) | |
| 849 | .into_iter() | |
| 850 | .map(|line| { | |
| 851 | let (bang, p) = match line.strip_prefix('!') { | |
| 852 | Some(rest) => ("!", rest.trim().to_owned()), | |
| 853 | None => ("", line), | |
| 854 | }; | |
| 855 | let p = if p == "~" { | |
| 856 | home.to_owned() | |
| 857 | } else if let Some(rest) = p.strip_prefix("~/") { | |
| 858 | format!("{home}/{rest}") | |
| 859 | } else { | |
| 860 | p | |
| 861 | }; | |
| 862 | let p = if p.starts_with('/') { p } else { format!("{}/{}", workspace.trim_end_matches('/'), p.trim_start_matches("./")) }; | |
| 863 | format!("{bang}{}", p.trim_end_matches('/')) | |
| 864 | }) | |
| 865 | .collect() | |
| 866 | } | |
| 867 | ||
| 868 | /// A path pattern as a word bash expands as a glob: everything but `*`, | |
| 869 | /// `?` and `[...]` escaped, so spaces and quotes stay literal. | |
| 870 | fn glob_word(pattern: &str) -> String { | |
| 871 | let mut out = String::new(); | |
| 872 | for c in pattern.chars() { | |
| 873 | if c.is_ascii_alphanumeric() || matches!(c, '*' | '?' | '[' | ']' | '/' | '.' | '-' | '_' | '~' | '+' | ',' | '=' | '@' | ':') { | |
| 874 | out.push(c); | |
| 875 | } else { | |
| 876 | out.push('\\'); | |
| 877 | out.push(c); | |
| 878 | } | |
| 879 | } | |
| 880 | out | |
| 881 | } | |
| 882 | ||
| 883 | /// The script that packs a cache: its patterns expanded (`**` reaching | |
| 884 | /// any depth), `!` patterns left out, into a tar archive compressed with | |
| 885 | /// zstd where there is one, else gzip. Exits 3 when nothing matched. | |
| 886 | fn pack_script(patterns: &[String], archive: &str) -> String { | |
| 887 | let includes: Vec<String> = patterns.iter().filter(|p| !p.starts_with('!')).map(|p| glob_word(p)).collect(); | |
| 888 | let excludes: Vec<String> = patterns | |
| 889 | .iter() | |
| 890 | .filter_map(|p| p.strip_prefix('!')) | |
| 891 | // tar's patterns: `*` already crosses `/`, so `**` is the same. | |
| 892 | .map(|p| format!("--exclude={}", quote(&p.replace("**", "*")))) | |
| 893 | .collect(); | |
| 894 | format!( | |
| 895 | "set -o pipefail; shopt -s globstar nullglob dotglob; found=( {} ); files=(); \ | |
| 896 | for f in \"${{found[@]}}\"; do if [ -e \"$f\" ]; then files+=(\"$f\"); fi; done; \ | |
| 897 | if [ ${{#files[@]}} -eq 0 ]; then exit 3; fi; \ | |
| 898 | if command -v zstd >/dev/null; then compress='zstd -T0 -3'; else compress=gzip; fi; \ | |
| 899 | tar -cPf {} -I \"$compress\" {} -- \"${{files[@]}}\"", | |
| 900 | includes.join(" "), | |
| 901 | quote(archive), | |
| 902 | excludes.join(" ") | |
| 903 | ) | |
| 904 | } | |
| 905 | ||
| A repository has its own sidebar, as settings do | 906 | fn urlencode(text: &str) -> String { |
| 907 | let mut out = String::new(); | |
| 908 | for byte in text.bytes() { | |
| 909 | if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { | |
| 910 | out.push(byte as char); | |
| 911 | } else { | |
| 912 | out.push_str(&format!("%{byte:02X}")); | |
| 913 | } | |
| 914 | } | |
| 915 | out | |
| 916 | } | |
| 917 | ||
| 918 | #[cfg(test)] | |
| 919 | mod tests { | |
| 920 | use super::*; | |
| 921 | ||
| 922 | #[test] | |
| Actions: keep workflow runs safe | 923 | fn a_cache_entrys_version_follows_its_paths_and_compression() { |
| 924 | let version = cache_version("~/.cargo/registry\ntarget", "zstd"); | |
| 925 | assert_eq!(version.len(), 64); | |
| 926 | assert_eq!(version, cache_version("~/.cargo/registry\n\ntarget\n", "zstd"), "blank lines do not count"); | |
| 927 | assert_ne!(version, cache_version("~/.cargo/registry", "zstd")); | |
| 928 | assert_ne!(version, cache_version("~/.cargo/registry\ntarget", "gzip")); | |
| 929 | } | |
| 930 | ||
| 931 | #[test] | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 932 | fn cache_paths_take_home_globs_and_exclusions() { |
| 933 | let paths = cache_patterns("~/.cargo/registry/cache\ntarget/*/release/\n!target/**/incremental\n./dist\n/abs/x\n~", "/home/node", "/w/repo/"); | |
| 934 | assert_eq!( | |
| 935 | paths, | |
| 936 | ["/home/node/.cargo/registry/cache", "/w/repo/target/*/release", "!/w/repo/target/**/incremental", "/w/repo/dist", "/abs/x", "/home/node"] | |
| 937 | ); | |
| 938 | assert_eq!(glob_word("/w/my repo/target/**/*.rlib"), "/w/my\\ repo/target/**/*.rlib"); | |
| 939 | assert_eq!(glob_word("/w/a'b"), "/w/a\\'b"); | |
| 940 | } | |
| 941 | ||
| 942 | #[test] | |
| 943 | fn packing_expands_globs_and_leaves_exclusions_out() { | |
| 944 | let script = pack_script(&["/w/target/*/release".into(), "!/w/target/**/incremental".into()], "/t/c.tar"); | |
| 945 | assert!(script.contains("found=( /w/target/*/release )"), "{script}"); | |
| 946 | assert!(script.contains("--exclude='/w/target/*/incremental'"), "{script}"); | |
| 947 | assert!(script.contains("zstd -T0"), "{script}"); | |
| 948 | assert!(script.contains("exit 3"), "{script}"); | |
| 949 | } | |
| 950 | ||
| 951 | /// Packs and unpacks for real, where bash and tar are (not on Windows). | |
| 952 | #[test] | |
| 953 | #[cfg(unix)] | |
| 954 | fn a_packed_cache_unpacks_without_what_was_left_out() { | |
| 955 | let dir = std::env::temp_dir().join(format!("g1t-cache-test-{}", std::process::id())); | |
| 956 | let _ = std::fs::remove_dir_all(&dir); | |
| 957 | for file in ["target/release/deps/a.rlib", "target/release/incremental/x.bin", "target/wasm/release/deps/b.rlib", "src/main.rs"] { | |
| 958 | let path = dir.join(file); | |
| 959 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | |
| 960 | std::fs::write(&path, file).unwrap(); | |
| 961 | } | |
| 962 | let root = dir.display().to_string(); | |
| 963 | let patterns = cache_patterns("target/**/deps\ntarget/release/incremental\n!target/**/incremental", "/home/node", &root); | |
| 964 | let archive = dir.join("c.tar").display().to_string(); | |
| 965 | let status = Command::new("bash").args(["-c", &pack_script(&patterns, &archive)]).status().unwrap(); | |
| 966 | assert!(status.success()); | |
| 967 | let listed = Command::new("tar").args(["-tPf", &archive]).output().unwrap(); | |
| 968 | let listed = String::from_utf8_lossy(&listed.stdout); | |
| 969 | assert!(listed.contains("deps/a.rlib") && listed.contains("deps/b.rlib"), "{listed}"); | |
| 970 | assert!(!listed.contains("incremental") && !listed.contains("main.rs"), "{listed}"); | |
| 971 | let none = Command::new("bash").args(["-c", &pack_script(&[format!("{root}/nothing/*")], &archive)]).status().unwrap(); | |
| 972 | assert_eq!(none.code(), Some(3)); | |
| 973 | let _ = std::fs::remove_dir_all(&dir); | |
| 974 | } | |
| 975 | ||
| 976 | #[test] | |
| A repository has its own sidebar, as settings do | 977 | fn keys_are_encoded_and_names_checked() { |
| 978 | assert_eq!(urlencode("Linux-node-abc/1 2"), "Linux-node-abc%2F1%202"); | |
| Merge main into the run-protection branch | 979 | assert!(check_name("coverage report").is_ok()); |
| 980 | assert!(check_name("dist-linux_x64.1 (debug)").is_ok()); | |
| 981 | assert!(check_name("ünïcødé").is_ok()); | |
| 982 | assert!(check_name(&"a".repeat(256)).is_ok()); | |
| 983 | assert!(check_name(&"a".repeat(257)).is_err()); | |
| 984 | assert!(check_name("").is_err()); | |
| 985 | 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"] { | |
| 986 | assert!(check_name(bad).is_err(), "{bad}"); | |
| 987 | } | |
| A repository has its own sidebar, as settings do | 988 | assert_eq!(lines("dist/\n\n# note\n coverage \n"), ["dist/", "coverage"]); |
| 989 | } | |
| Merge main into the run-protection branch | 990 | |
| 991 | fn with(pairs: &[(&str, &str)]) -> BTreeMap<String, String> { | |
| 992 | pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() | |
| 993 | } | |
| 994 | ||
| 995 | #[test] | |
| 996 | fn upload_inputs_are_read_as_v4_reads_them() { | |
| 997 | assert_eq!(keep(&with(&[]), true).unwrap(), Keep { retention: 0, level: 6, overwrite: false, hidden: false }); | |
| 998 | let set = with(&[("retention-days", "14"), ("compression-level", "0"), ("overwrite", "true"), ("include-hidden-files", "True")]); | |
| 999 | assert_eq!(keep(&set, true).unwrap(), Keep { retention: 14, level: 0, overwrite: true, hidden: true }); | |
| 1000 | // The merge takes no `overwrite`. | |
| 1001 | assert!(!keep(&set, false).unwrap().overwrite); | |
| 1002 | assert_eq!(keep(&with(&[("retention-days", "0")]), true).unwrap().retention, 0); | |
| 1003 | assert!(keep(&with(&[("retention-days", "91")]), true).is_err()); | |
| 1004 | assert!(keep(&with(&[("retention-days", "-1")]), true).is_err()); | |
| 1005 | assert!(keep(&with(&[("retention-days", "two")]), true).is_err()); | |
| 1006 | assert!(keep(&with(&[("compression-level", "10")]), true).is_err()); | |
| 1007 | assert!(keep(&with(&[("overwrite", "yes")]), true).is_err()); | |
| 1008 | assert_eq!(artifact_ids(" 12, 34 ,,").unwrap(), [12, 34]); | |
| 1009 | assert!(artifact_ids("12,abc").is_err()); | |
| 1010 | assert_eq!(count(1, "file"), "1 file"); | |
| 1011 | assert_eq!(count(37, "file"), "37 files"); | |
| 1012 | } | |
| 1013 | ||
| 1014 | fn listed(id: u64, name: &str) -> Listed { | |
| 1015 | Listed { id, name: name.into(), size: 0, digest: None, format: "zip".into() } | |
| 1016 | } | |
| 1017 | ||
| 1018 | #[test] | |
| 1019 | fn listings_read_and_keep_the_newest_of_a_name() { | |
| 1020 | let json = serde_json::json!([ | |
| 1021 | { "id": 1, "name": "dist", "size": 10, "digest": null, "format": "tgz", "created_at": "x", "expires_at": "y" }, | |
| 1022 | { "id": "3", "name": "dist", "size": 30, "digest": "sha256:ab", "format": "zip" }, | |
| 1023 | { "id": 2, "name": "logs", "size": 5 }, | |
| 1024 | { "name": "no id" } | |
| 1025 | ]); | |
| 1026 | let all: Vec<Listed> = json.as_array().unwrap().iter().filter_map(Listed::from_json).collect(); | |
| 1027 | assert_eq!(all.len(), 3); | |
| 1028 | assert_eq!(all[0].format, "tgz"); | |
| 1029 | let kept = newest(all); | |
| 1030 | assert_eq!(kept.iter().map(|a| (a.id, a.name.as_str())).collect::<Vec<_>>(), [(3, "dist"), (2, "logs")]); | |
| 1031 | assert_eq!(kept[0].digest.as_deref(), Some("sha256:ab")); | |
| 1032 | } | |
| 1033 | ||
| 1034 | #[test] | |
| 1035 | fn downloads_land_where_v4_puts_them() { | |
| 1036 | let dest = Path::new("/w/out"); | |
| 1037 | let one = [listed(1, "dist")]; | |
| 1038 | let two = [listed(1, "dist"), listed(2, "logs")]; | |
| 1039 | let at = |targets: Vec<Option<std::path::PathBuf>>| targets.into_iter().map(|t| t.unwrap()).collect::<Vec<_>>(); | |
| 1040 | // By name: straight into `path`. | |
| 1041 | assert_eq!(at(download_targets(dest, &one, true, false, false)), [dest.to_path_buf()]); | |
| 1042 | // Every artifact, or by pattern: a folder each. | |
| 1043 | assert_eq!(at(download_targets(dest, &two, false, false, false)), [dest.join("dist"), dest.join("logs")]); | |
| 1044 | assert_eq!(at(download_targets(dest, &one, false, false, false)), [dest.join("dist")]); | |
| 1045 | // merge-multiple: all into `path`. | |
| 1046 | assert_eq!(at(download_targets(dest, &two, false, false, true)), [dest.to_path_buf(), dest.to_path_buf()]); | |
| 1047 | // By id: one straight into `path`, several a folder each. | |
| 1048 | assert_eq!(at(download_targets(dest, &one, false, true, false)), [dest.to_path_buf()]); | |
| 1049 | assert_eq!(at(download_targets(dest, &two, false, true, false)), [dest.join("dist"), dest.join("logs")]); | |
| 1050 | // A name that is no folder is refused. | |
| 1051 | assert!(download_targets(dest, &[listed(1, ".."), listed(2, "x")], false, false, false)[0].is_none()); | |
| 1052 | assert!(glob::matches_part("dist-*", "dist-linux")); | |
| 1053 | assert!(!glob::matches_part("dist-*", "logs")); | |
| 1054 | } | |
| A repository has its own sidebar, as settings do | 1055 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.