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 remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 1 | //! Actions from GitHub, kept on g1t. |
| 2 | //! | |
| 3 | //! An action `uses:` names that g1t does not host (reach.rs) comes from | |
| 4 | //! GitHub. Before a job fetches it, g1t resolves its ref to a commit, as | |
| 5 | //! GitHub's runner does, from the repository's advertisement | |
| 6 | //! (`info/refs`, which no API rate limit applies to), and keeps that | |
| 7 | //! commit's archive in R2. Every later job that uses the action at that | |
| 8 | //! commit downloads it from g1t: faster than GitHub, the same bytes | |
| 9 | //! whatever happens to the tag, and there while GitHub is not. | |
| 10 | //! | |
| 11 | //! - A ref is looked up again once `REF_FRESH_MS` has passed, so a moved | |
| 12 | //! tag (`v4` moving to a new release) is followed within minutes. When | |
| 13 | //! GitHub does not answer, the commit the ref last named is used, and | |
| 14 | //! the job says so. | |
| 15 | //! - A commit's archive is the one GitHub serves for it, which never | |
| 16 | //! changes. One larger than `ARCHIVE_MAX_BYTES` is not kept: the job | |
| 17 | //! fetches it from GitHub itself. | |
| 18 | //! - An archive no job has used for `ARCHIVE_UNUSED_DAYS` is deleted by | |
| 19 | //! the hourly sweep. Archives are g1t's own, shared by every workspace, | |
| 20 | //! and charged to none. | |
| 21 | ||
| 22 | use futures_util::StreamExt; | |
| 23 | use g1t_contracts::time::rfc3339; | |
| 24 | use g1t_kit::now_ms; | |
| 25 | use serde::Deserialize; | |
| 26 | use serde_json::{Value, json}; | |
| 27 | use worker::Result; | |
| 28 | ||
| 29 | use crate::Actions; | |
| 30 | use crate::reach::UsesRef; | |
| 31 | ||
| 32 | /// The largest archive kept: the Worker holds it whole while saving it. | |
| 33 | pub(crate) const ARCHIVE_MAX_BYTES: usize = 32 * 1024 * 1024; | |
| 34 | /// How long a ref's commit is trusted before GitHub is asked again. | |
| 35 | pub(crate) const REF_FRESH_MS: u64 = 5 * 60 * 1000; | |
| 36 | /// An archive unused this long is deleted. | |
| 37 | pub(crate) const ARCHIVE_UNUSED_DAYS: u64 = 30; | |
| 38 | /// An advertisement larger than this is not read: a ref is never worth it. | |
| 39 | const ADVERTISEMENT_MAX_BYTES: usize = 8 * 1024 * 1024; | |
| 40 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 41 | ||
| 42 | /// Whether `text` is a whole commit id. | |
| 43 | pub(crate) fn is_sha(text: &str) -> bool { | |
| 44 | text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit()) | |
| 45 | } | |
| 46 | ||
| 47 | /// The refs a smart HTTP advertisement (`info/refs?service=git-upload-pack`) | |
| 48 | /// lists, as (commit, name). Peeled tags keep their `^{}`. | |
| 49 | pub(crate) fn parse_advertisement(bytes: &[u8]) -> Vec<(String, String)> { | |
| 50 | let mut refs = Vec::new(); | |
| 51 | let mut at = 0; | |
| 52 | while at + 4 <= bytes.len() { | |
| 53 | let Some(length) = std::str::from_utf8(&bytes[at..at + 4]).ok().and_then(|hex| usize::from_str_radix(hex, 16).ok()) else { | |
| 54 | break; | |
| 55 | }; | |
| 56 | // A flush or delimiter packet. | |
| 57 | if length < 4 { | |
| 58 | at += 4; | |
| 59 | continue; | |
| 60 | } | |
| 61 | if at + length > bytes.len() { | |
| 62 | break; | |
| 63 | } | |
| 64 | let line = String::from_utf8_lossy(&bytes[at + 4..at + length]); | |
| 65 | at += length; | |
| 66 | let line = line.trim_end_matches('\n'); | |
| 67 | if line.starts_with('#') { | |
| 68 | continue; | |
| 69 | } | |
| 70 | // The first ref carries the capabilities after a NUL. | |
| 71 | let line = line.split('\0').next().unwrap_or_default(); | |
| 72 | if let Some((sha, name)) = line.split_once(' ') | |
| 73 | && is_sha(sha) | |
| 74 | { | |
| 75 | refs.push((sha.to_ascii_lowercase(), name.to_owned())); | |
| 76 | } | |
| 77 | } | |
| 78 | refs | |
| 79 | } | |
| 80 | ||
| 81 | /// The commit `wanted` names among `refs`, as git reads a short name: a | |
| 82 | /// commit id as it is, else a tag, else a branch (`refs/…` names one | |
| 83 | /// exactly). A tag's commit is its peeled one. | |
| 84 | pub(crate) fn pick(refs: &[(String, String)], wanted: &str) -> Option<String> { | |
| 85 | if is_sha(wanted) { | |
| 86 | return Some(wanted.to_ascii_lowercase()); | |
| 87 | } | |
| 88 | let names: Vec<String> = if wanted.starts_with("refs/") { | |
| 89 | vec![wanted.to_owned()] | |
| 90 | } else { | |
| 91 | vec![format!("refs/tags/{wanted}"), format!("refs/heads/{wanted}")] | |
| 92 | }; | |
| 93 | names.iter().find_map(|name| { | |
| 94 | let peeled = format!("{name}^{{}}"); | |
| 95 | refs.iter() | |
| 96 | .find(|(_, listed)| *listed == peeled) | |
| 97 | .or_else(|| refs.iter().find(|(_, listed)| listed == name)) | |
| 98 | .map(|(sha, _)| sha.clone()) | |
| 99 | }) | |
| 100 | } | |
| 101 | ||
| 102 | /// Where a commit's archive is kept in R2. | |
| 103 | pub(crate) fn archive_object(repository: &str, sha: &str) -> String { | |
| 104 | format!("action-archives/{}/{sha}.tar.gz", repository.to_ascii_lowercase()) | |
| 105 | } | |
| 106 | ||
| 107 | /// A GET to GitHub, whose body is read up to `max` bytes. None when it | |
| 108 | /// does not answer 200, or sends more. | |
| 109 | async fn fetch_capped(url: &str, max: usize) -> Option<Vec<u8>> { | |
| 110 | let headers = worker::Headers::new(); | |
| 111 | // GitHub answers a smart HTTP advertisement to git, which it knows by | |
| 112 | // its agent. | |
| 113 | headers.set("user-agent", "git/2.47.0 (g1t)").ok()?; | |
| 114 | let mut init = worker::RequestInit::new(); | |
| 115 | init.with_method(worker::Method::Get).with_headers(headers); | |
| 116 | let request = worker::Request::new_with_init(url, &init).ok()?; | |
| 117 | let mut response = worker::Fetch::Request(request).send().await.ok()?; | |
| 118 | if response.status_code() != 200 { | |
| 119 | return None; | |
| 120 | } | |
| 121 | let mut stream = response.stream().ok()?; | |
| 122 | let mut body = Vec::new(); | |
| 123 | while let Some(chunk) = stream.next().await { | |
| 124 | body.extend_from_slice(&chunk.ok()?); | |
| 125 | if body.len() > max { | |
| 126 | return None; | |
| 127 | } | |
| 128 | } | |
| 129 | Some(body) | |
| 130 | } | |
| 131 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 132 | /// The status GitHub answers a GET with, or None when it doesn't answer. |
| 133 | async fn status_of(url: &str) -> Option<u16> { | |
| 134 | let headers = worker::Headers::new(); | |
| 135 | headers.set("user-agent", "git/2.47.0 (g1t)").ok()?; | |
| 136 | let mut init = worker::RequestInit::new(); | |
| 137 | init.with_method(worker::Method::Get).with_headers(headers); | |
| 138 | let request = worker::Request::new_with_init(url, &init).ok()?; | |
| 139 | let response = worker::Fetch::Request(request).send().await.ok()?; | |
| 140 | Some(response.status_code()) | |
| 141 | } | |
| 142 | ||
| 143 | /// What GitHub's answer to a repository's advertisement says about it: | |
| 144 | /// Some(true) when it publishes the repository, Some(false) when it says | |
| 145 | /// there is none it will show (GitHub answers 401 for a repository that | |
| 146 | /// doesn't exist as for a private one), None for anything else, such as a | |
| 147 | /// rate limit or an error. | |
| 148 | pub(crate) fn published_by(status: Option<u16>) -> Option<bool> { | |
| 149 | match status? { | |
| 150 | 200 => Some(true), | |
| 151 | 401 | 404 => Some(false), | |
| 152 | _ => None, | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 156 | #[derive(Deserialize)] |
| 157 | struct RefRow { | |
| 158 | sha: String, | |
| 159 | resolved_at: String, | |
| 160 | } | |
| 161 | ||
| 162 | impl Actions { | |
| 163 | /// Where a job gets `used`, an action g1t does not host: | |
| 164 | /// `{"source": "github", "sha", "archive", "note"}`. `sha` is the commit | |
| 165 | /// its ref names (absent when GitHub does not know it, or never | |
| 166 | /// answered), `archive` whether g1t keeps that commit's archive for the | |
| 167 | /// job to download, and `note` anything the job should say about it. | |
| 168 | pub(crate) async fn github_action(&self, used: &UsesRef) -> Result<Value> { | |
| 169 | let repository = used.full_name().to_ascii_lowercase(); | |
| 170 | let now = now_ms(); | |
| 171 | let (sha, note) = self.resolve_github(&repository, &used.git_ref, now).await?; | |
| 172 | let Some(sha) = sha else { | |
| 173 | return Ok(json!({ "source": "github", "note": note })); | |
| 174 | }; | |
| 175 | let archive = self.keep_archive(&repository, &sha, now).await?; | |
| 176 | Ok(json!({ "source": "github", "sha": sha, "archive": archive, "note": note })) | |
| 177 | } | |
| 178 | ||
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 179 | /// Whether GitHub publishes `repository` (`owner/repo`): Some(true) when |
| 180 | /// it does, or once named a commit of it for a job; Some(false) when it | |
| 181 | /// says it has no such repository; None when it could not say. | |
| 182 | pub(crate) async fn github_publishes(&self, repository: &str) -> Result<Option<bool>> { | |
| 183 | let repository = repository.to_ascii_lowercase(); | |
| 184 | let seen = self | |
| 185 | .db | |
| 186 | .prepare("SELECT sha FROM action_refs WHERE repository = ? LIMIT 1") | |
| 187 | .bind(&[repository.as_str().into()])? | |
| 188 | .first::<Value>(None) | |
| 189 | .await?; | |
| 190 | if seen.is_some() { | |
| 191 | return Ok(Some(true)); | |
| 192 | } | |
| 193 | Ok(published_by(status_of(&format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack")).await)) | |
| 194 | } | |
| 195 | ||
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 196 | /// The commit `git_ref` names in GitHub's `repository`, and a note when |
| 197 | /// it is the one it last named because GitHub did not answer. | |
| 198 | async fn resolve_github(&self, repository: &str, git_ref: &str, now: u64) -> Result<(Option<String>, Option<String>)> { | |
| 199 | if is_sha(git_ref) { | |
| 200 | return Ok((Some(git_ref.to_ascii_lowercase()), None)); | |
| 201 | } | |
| 202 | let known = self | |
| 203 | .db | |
| 204 | .prepare("SELECT sha, resolved_at FROM action_refs WHERE repository = ? AND ref = ?") | |
| 205 | .bind(&[repository.into(), git_ref.into()])? | |
| 206 | .first::<RefRow>(None) | |
| 207 | .await?; | |
| 208 | if let Some(row) = &known | |
| 209 | && row.resolved_at >= rfc3339(now.saturating_sub(REF_FRESH_MS)) | |
| 210 | { | |
| 211 | return Ok((Some(row.sha.clone()), None)); | |
| 212 | } | |
| 213 | let url = format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack"); | |
| 214 | match fetch_capped(&url, ADVERTISEMENT_MAX_BYTES).await { | |
| 215 | Some(advertisement) => { | |
| 216 | let Some(sha) = pick(&parse_advertisement(&advertisement), git_ref) else { | |
| 217 | return Ok((None, None)); | |
| 218 | }; | |
| 219 | self.db | |
| 220 | .prepare( | |
| 221 | "INSERT INTO action_refs (repository, ref, sha, resolved_at) VALUES (?1, ?2, ?3, ?4) | |
| 222 | ON CONFLICT (repository, ref) DO UPDATE SET sha = ?3, resolved_at = ?4", | |
| 223 | ) | |
| 224 | .bind(&[repository.into(), git_ref.into(), sha.as_str().into(), rfc3339(now).into()])? | |
| 225 | .run() | |
| 226 | .await?; | |
| 227 | Ok((Some(sha), None)) | |
| 228 | } | |
| 229 | None => Ok(match known { | |
| 230 | Some(row) => { | |
| 231 | let note = format!( | |
| 232 | "GitHub did not say which commit {repository}@{git_ref} is; using {}, which it named on {}.", | |
| 233 | &row.sha, | |
| 234 | row.resolved_at.get(..10).unwrap_or(&row.resolved_at) | |
| 235 | ); | |
| 236 | (Some(row.sha), Some(note)) | |
| 237 | } | |
| 238 | None => (None, None), | |
| 239 | }), | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | /// Whether g1t keeps the archive of `repository` at `sha`, fetching it | |
| 244 | /// from GitHub the first time. | |
| 245 | async fn keep_archive(&self, repository: &str, sha: &str, now: u64) -> Result<bool> { | |
| 246 | let Some(bucket) = &self.cache else { return Ok(false) }; | |
| 247 | #[derive(Deserialize)] | |
| 248 | struct Kept { | |
| 249 | used_at: String, | |
| 250 | } | |
| 251 | let kept = self | |
| 252 | .db | |
| 253 | .prepare("SELECT used_at FROM action_archives WHERE repository = ? AND sha = ?") | |
| 254 | .bind(&[repository.into(), sha.into()])? | |
| 255 | .first::<Kept>(None) | |
| 256 | .await?; | |
| 257 | if let Some(kept) = kept { | |
| 258 | // Marked used at most once a day: the sweep needs no more. | |
| 259 | if kept.used_at < rfc3339(now.saturating_sub(DAY_MS)) { | |
| 260 | self.db | |
| 261 | .prepare("UPDATE action_archives SET used_at = ? WHERE repository = ? AND sha = ?") | |
| 262 | .bind(&[rfc3339(now).into(), repository.into(), sha.into()])? | |
| 263 | .run() | |
| 264 | .await?; | |
| 265 | } | |
| 266 | return Ok(true); | |
| 267 | } | |
| 268 | let url = format!("https://codeload.github.com/{repository}/tar.gz/{sha}"); | |
| 269 | let Some(archive) = fetch_capped(&url, ARCHIVE_MAX_BYTES).await else { | |
| 270 | return Ok(false); | |
| 271 | }; | |
| 272 | let object = archive_object(repository, sha); | |
| 273 | let size = archive.len(); | |
| 274 | if let Err(error) = bucket.put(&object, archive).execute().await { | |
| 275 | worker::console_error!("actions: the archive of {repository}@{sha} was not kept: {error}"); | |
| 276 | return Ok(false); | |
| 277 | } | |
| 278 | let at = rfc3339(now); | |
| 279 | self.db | |
| 280 | .prepare( | |
| 281 | "INSERT INTO action_archives (repository, sha, object, size, stored_at, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5) | |
| 282 | ON CONFLICT (repository, sha) DO UPDATE SET used_at = ?5", | |
| 283 | ) | |
| 284 | .bind(&[repository.into(), sha.into(), object.into(), (size as f64).into(), at.into()])? | |
| 285 | .run() | |
| 286 | .await?; | |
| 287 | Ok(true) | |
| 288 | } | |
| 289 | ||
| 290 | /// `job_action_archive`: a running job downloads an action's archive | |
| 291 | /// g1t keeps, by `report.repository` and `report.sha`. Answers the R2 | |
| 292 | /// object to send, `{"object", "size"}`. Any job may: they are public | |
| 293 | /// repositories' code. | |
| 294 | pub async fn job_action_archive(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<g1t_contracts::Outcome<Value>> { | |
| 295 | crate::check!(self.job_for_token(&a).await?); | |
| 296 | let repository = a.report["repository"].as_str().unwrap_or_default().to_ascii_lowercase(); | |
| 297 | let sha = a.report["sha"].as_str().unwrap_or_default().to_ascii_lowercase(); | |
| 298 | if !is_sha(&sha) || crate::reach::parse_uses(&format!("{repository}@{sha}")).is_none() { | |
| 299 | return Ok(crate::fail(g1t_contracts::FailureCode::Invalid, "Name the action's repository as owner/repo, and its commit.")); | |
| 300 | } | |
| 301 | #[derive(Deserialize)] | |
| 302 | struct Kept { | |
| 303 | object: String, | |
| 304 | size: f64, | |
| 305 | } | |
| 306 | let kept = self | |
| 307 | .db | |
| 308 | .prepare("SELECT object, size FROM action_archives WHERE repository = ? AND sha = ?") | |
| 309 | .bind(&[repository.as_str().into(), sha.as_str().into()])? | |
| 310 | .first::<Kept>(None) | |
| 311 | .await?; | |
| 312 | Ok(match kept { | |
| 313 | Some(kept) => g1t_contracts::Outcome::Ok(json!({ "object": kept.object, "size": kept.size })), | |
| 314 | None => crate::fail(g1t_contracts::FailureCode::NotFound, format!("g1t does not keep {repository}@{sha}.")), | |
| 315 | }) | |
| 316 | } | |
| 317 | ||
| 318 | /// Hourly: archives no job has used for `ARCHIVE_UNUSED_DAYS`, and refs | |
| 319 | /// not looked up as long. | |
| 320 | pub async fn sweep_action_archives(&self, now: u64) -> Result<()> { | |
| 321 | let before = rfc3339(now.saturating_sub(ARCHIVE_UNUSED_DAYS * DAY_MS)); | |
| 322 | #[derive(Deserialize)] | |
| 323 | struct Gone { | |
| 324 | repository: String, | |
| 325 | sha: String, | |
| 326 | object: String, | |
| 327 | } | |
| 328 | let gone = self | |
| 329 | .db | |
| 330 | .prepare("SELECT repository, sha, object FROM action_archives WHERE used_at < ? LIMIT 100") | |
| 331 | .bind(&[before.as_str().into()])? | |
| 332 | .all() | |
| 333 | .await? | |
| 334 | .results::<Gone>()?; | |
| 335 | if let Some(bucket) = &self.cache | |
| 336 | && !gone.is_empty() | |
| 337 | { | |
| 338 | bucket.delete_multiple(gone.iter().map(|g| g.object.as_str()).collect()).await?; | |
| 339 | for archive in &gone { | |
| 340 | self.db | |
| 341 | .prepare("DELETE FROM action_archives WHERE repository = ? AND sha = ?") | |
| 342 | .bind(&[archive.repository.as_str().into(), archive.sha.as_str().into()])? | |
| 343 | .run() | |
| 344 | .await?; | |
| 345 | } | |
| 346 | } | |
| 347 | self.db.prepare("DELETE FROM action_refs WHERE resolved_at < ?").bind(&[before.into()])?.run().await?; | |
| 348 | Ok(()) | |
| 349 | } | |
| 350 | } | |
| 351 | ||
| 352 | #[cfg(test)] | |
| 353 | mod tests { | |
| 354 | use super::*; | |
| 355 | ||
| 356 | fn pkt(payload: &str) -> Vec<u8> { | |
| 357 | let mut out = format!("{:04x}", payload.len() + 4).into_bytes(); | |
| 358 | out.extend_from_slice(payload.as_bytes()); | |
| 359 | out | |
| 360 | } | |
| 361 | ||
| 362 | const MAIN: &str = "1111111111111111111111111111111111111111"; | |
| 363 | const V4_TAG: &str = "2222222222222222222222222222222222222222"; | |
| 364 | const V4_COMMIT: &str = "3333333333333333333333333333333333333333"; | |
| 365 | const V1_BRANCH: &str = "4444444444444444444444444444444444444444"; | |
| 366 | const V1_TAG: &str = "5555555555555555555555555555555555555555"; | |
| 367 | ||
| 368 | fn advertisement() -> Vec<u8> { | |
| 369 | let mut bytes = pkt("# service=git-upload-pack\n"); | |
| 370 | bytes.extend_from_slice(b"0000"); | |
| 371 | bytes.extend(pkt(&format!("{MAIN} HEAD\0multi_ack thin-pack symref=HEAD:refs/heads/main\n"))); | |
| 372 | bytes.extend(pkt(&format!("{MAIN} refs/heads/main\n"))); | |
| 373 | bytes.extend(pkt(&format!("{V1_BRANCH} refs/heads/v1\n"))); | |
| 374 | bytes.extend(pkt(&format!("{V1_TAG} refs/tags/v1\n"))); | |
| 375 | bytes.extend(pkt(&format!("{V4_TAG} refs/tags/v4\n"))); | |
| 376 | bytes.extend(pkt(&format!("{V4_COMMIT} refs/tags/v4^{{}}\n"))); | |
| 377 | bytes.extend_from_slice(b"0000"); | |
| 378 | bytes | |
| 379 | } | |
| 380 | ||
| 381 | #[test] | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 382 | fn only_an_answer_from_github_says_it_has_no_such_repository() { |
| 383 | assert_eq!(published_by(Some(200)), Some(true)); | |
| 384 | assert_eq!(published_by(Some(401)), Some(false)); | |
| 385 | assert_eq!(published_by(Some(404)), Some(false)); | |
| 386 | // A rate limit, an error or no answer says nothing, so the name stays GitHub's. | |
| 387 | assert_eq!(published_by(Some(429)), None); | |
| 388 | assert_eq!(published_by(Some(503)), None); | |
| 389 | assert_eq!(published_by(None), None); | |
| 390 | } | |
| 391 | ||
| 392 | #[test] | |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 393 | fn an_advertisement_lists_its_refs_capabilities_aside() { |
| 394 | let refs = parse_advertisement(&advertisement()); | |
| 395 | assert_eq!(refs.len(), 6); | |
| 396 | assert_eq!(refs[0], (MAIN.to_owned(), "HEAD".to_owned())); | |
| 397 | assert_eq!(refs[5], (V4_COMMIT.to_owned(), "refs/tags/v4^{}".to_owned())); | |
| 398 | // Cut short: what came whole is kept. | |
| 399 | let whole = advertisement(); | |
| 400 | assert_eq!(parse_advertisement(&whole[..whole.len() - 30]).len(), 5); | |
| 401 | assert!(parse_advertisement(b"not git").is_empty()); | |
| 402 | } | |
| 403 | ||
| 404 | #[test] | |
| 405 | fn a_ref_is_read_as_git_reads_a_short_name() { | |
| 406 | let refs = parse_advertisement(&advertisement()); | |
| 407 | // A tag's commit, not the tag object. | |
| 408 | assert_eq!(pick(&refs, "v4").as_deref(), Some(V4_COMMIT)); | |
| 409 | // A tag before a branch of the same name. | |
| 410 | assert_eq!(pick(&refs, "v1").as_deref(), Some(V1_TAG)); | |
| 411 | assert_eq!(pick(&refs, "refs/heads/v1").as_deref(), Some(V1_BRANCH)); | |
| 412 | assert_eq!(pick(&refs, "main").as_deref(), Some(MAIN)); | |
| 413 | // A commit is itself, known or not. | |
| 414 | let other = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; | |
| 415 | assert_eq!(pick(&refs, other).as_deref(), Some("abcdef0123456789abcdef0123456789abcdef01")); | |
| 416 | assert_eq!(pick(&refs, "v9"), None); | |
| 417 | assert_eq!(pick(&refs, "abc123"), None); | |
| 418 | } | |
| 419 | ||
| 420 | #[test] | |
| 421 | fn archives_are_kept_by_repository_and_commit() { | |
| 422 | assert_eq!(archive_object("Actions/Checkout", V4_COMMIT), format!("action-archives/actions/checkout/{V4_COMMIT}.tar.gz")); | |
| 423 | assert!(is_sha(MAIN)); | |
| 424 | assert!(!is_sha("v4")); | |
| 425 | } | |
| 426 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.