Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'
12 files+638−160/12 viewed
| 924 | 924 | version = "0.1.0" | |
| 925 | 925 | dependencies = [ | |
| 926 | 926 | "base64 0.22.1", | |
| 927 | + | "futures-util", | |
| 927 | 928 | "g1t-actions", | |
| 928 | 929 | "g1t-contracts", | |
| 929 | 930 | "g1t-kit", |
| 728 | 728 | } | |
| 729 | 729 | // A sandbox running a GitHub Actions job: fetching the job, and | |
| 730 | 730 | // reporting how it goes. The job's own token is the credential. | |
| 731 | + | // A sandbox downloading an action's archive that g1t keeps | |
| 732 | + | // (actions' registry.rs). The job's own token is the credential. | |
| 733 | + | ("POST", path) if path.starts_with("/actions/jobs/") && path.ends_with("/archive") => { | |
| 734 | + | let job = path.trim_start_matches("/actions/jobs/").trim_end_matches("/archive").to_owned(); | |
| 735 | + | let body = json_body(&mut request).await; | |
| 736 | + | let kept: Outcome<Value> = g1t_kit::call( | |
| 737 | + | &services.actions, | |
| 738 | + | "job_action_archive", | |
| 739 | + | &json!({ "job": job, "token": body["token"], "report": body["report"] }), | |
| 740 | + | ) | |
| 741 | + | .await?; | |
| 742 | + | let kept = match kept { | |
| 743 | + | Outcome::Ok(kept) => kept, | |
| 744 | + | Outcome::Fail(refused) => return failure(&refused), | |
| 745 | + | }; | |
| 746 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 747 | + | let object = kept["object"].as_str().unwrap_or_default(); | |
| 748 | + | let Some(stored) = bucket.get(object).execute().await? else { | |
| 749 | + | return fail(FailureCode::NotFound, "The archive is gone."); | |
| 750 | + | }; | |
| 751 | + | let Some(body) = stored.body() else { | |
| 752 | + | return fail(FailureCode::NotFound, "The archive is gone."); | |
| 753 | + | }; | |
| 754 | + | let mut response = Response::from_body(body.response_body()?)?; | |
| 755 | + | response.headers_mut().set("content-type", "application/gzip")?; | |
| 756 | + | return Ok(response); | |
| 757 | + | } | |
| 731 | 758 | ("POST", path) if path.starts_with("/actions/jobs/") => { | |
| 732 | 759 | let rest = path.trim_start_matches("/actions/jobs/"); | |
| 733 | 760 | // `/action`: where to fetch another repository's action from (on |
| 133 | 133 | | On g1t and public | Your workflows use it, from any workspace. | | |
| 134 | 134 | | On g1t, private, in your workspace, with **Access** set to *Accessible from repositories in* the workspace | Your private repositories' workflows use it. A job reads it with a read-only token for that repository alone, which ends with the job. | | |
| 135 | 135 | | On g1t, private, and not shared that way | The step or job fails, and says why. A private repository's actions are never used by a public repository's workflows, whose logs anyone can read, nor from another workspace. | | |
| 136 | − | | Not on g1t, or private in a workspace you cannot see | An action is fetched from GitHub, as before; a reusable workflow is read from a public repository on GitHub. | | |
| 136 | + | | Not on g1t, or private in a workspace you cannot see | An action comes from GitHub, at the commit its ref names there (see [actions from GitHub](#actions-from-github)); a reusable workflow is read from a public repository on GitHub. | | |
| 137 | 137 | ||
| 138 | 138 | `ref` is a branch, a tag or a commit. A reusable workflow may be under | |
| 139 | 139 | `.g1t/workflows/` or `.github/workflows/`; a `.github/workflows/` path | |
| ⋯ | |||
| 147 | 147 | `PUT /repos/{owner}/{repo}/actions/permissions/access` with | |
| 148 | 148 | `{"access_level": "organization"}` (`none` to stop). | |
| 149 | 149 | ||
| 150 | + | ### Actions from GitHub | |
| 151 | + | ||
| 152 | + | g1t keeps the actions your workflows use from GitHub, so a job downloads | |
| 153 | + | them from g1t rather than from GitHub: | |
| 154 | + | ||
| 155 | + | 1. Before the first step, the job downloads every action its steps name, | |
| 156 | + | as GitHub's runner does in **Set up job**, and fails there if one can't | |
| 157 | + | be had. Its log names each one's commit: | |
| 158 | + | `Download action repository 'actions/setup-node@v4' (SHA:…) from g1t`. | |
| 159 | + | 2. g1t reads which commit the ref (`v4`, a branch, or a commit) names on | |
| 160 | + | GitHub, a tag before a branch of the same name, and looks again at | |
| 161 | + | most every 5 minutes, so a tag that moves is followed within minutes. | |
| 162 | + | 3. The first job to use an action at a commit has g1t fetch that commit's | |
| 163 | + | archive from GitHub and keep it. Every later job, in any workspace, | |
| 164 | + | downloads the same bytes from g1t. | |
| 165 | + | ||
| 166 | + | While GitHub doesn't answer, a ref reads as the commit it last named, and | |
| 167 | + | the job says so in a warning; an action g1t keeps still downloads. An | |
| 168 | + | action whose archive is larger than 32 MB, or from a private repository | |
| 169 | + | on GitHub, is downloaded from GitHub by the job itself. An archive no job | |
| 170 | + | has used for 30 days is deleted, and fetched again when next used. | |
| 171 | + | Kept archives are g1t's and count toward no workspace's storage. | |
| 172 | + | ||
| 173 | + | Actions that a composite action uses are downloaded when it runs. | |
| 174 | + | ||
| 150 | 175 | ### Secrets for a called workflow | |
| 151 | 176 | ||
| 152 | 177 | A called workflow gets only the secrets its caller passes, plus | |
| 750 | 750 | job.docker_hosted = false; | |
| 751 | 751 | } | |
| 752 | 752 | } | |
| 753 | − | let containers_started = job.start_containers(); | |
| 753 | + | // Actions first, then containers, as GitHub's "Set up job" and | |
| 754 | + | // "Initialize containers". | |
| 755 | + | let containers_started = job.prepare_actions(&steps) && job.start_containers(); | |
| 754 | 756 | job.log.flush(); | |
| 755 | 757 | ||
| 756 | 758 | let mut frame = Frame::default(); |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | /// Where to fetch another repository's action from: `{"source": "g1t", | |
| 88 | − | /// "url", "ref", "token"}` or `{"source": "github"}`. Err holds why g1t | |
| 88 | + | /// "url", "ref", "token"}`, or `{"source": "github", "sha", "archive", | |
| 89 | + | /// "note"}`: the commit GitHub's ref names, and whether g1t keeps its | |
| 90 | + | /// archive (`action_archive`). Err holds why g1t | |
| 89 | 91 | /// refused (`true`: the repository is private and may not be used | |
| 90 | 92 | /// here), or that it could not be asked (`false`). | |
| 91 | 93 | pub(crate) fn action(&self, repository: &str, git_ref: &str) -> std::result::Result<Value, (bool, String)> { | |
| 94 | + | // The first job to use an action at a commit waits while g1t | |
| 95 | + | // fetches and keeps its archive. | |
| 92 | 96 | let sent = ureq::post(&format!("{}/actions/jobs/{}/action", self.base, self.job)) | |
| 93 | − | .timeout(Duration::from_secs(30)) | |
| 97 | + | .timeout(Duration::from_secs(120)) | |
| 94 | 98 | .send_json(json!({ "token": self.token, "report": { "repository": repository, "ref": git_ref } })); | |
| 95 | 99 | match sent { | |
| 96 | 100 | Ok(response) => response.into_json().map_err(|error| (false, error.to_string())), | |
| ⋯ | |||
| 103 | 107 | } | |
| 104 | 108 | } | |
| 105 | 109 | ||
| 110 | + | /// Downloads the archive g1t keeps of `repository` at `sha` to `to`. | |
| 111 | + | pub(crate) fn action_archive(&self, repository: &str, sha: &str, to: &std::path::Path) -> std::result::Result<u64, String> { | |
| 112 | + | let response = ureq::post(&format!("{}/actions/jobs/{}/archive", self.base, self.job)) | |
| 113 | + | .timeout(Duration::from_secs(300)) | |
| 114 | + | .send_json(json!({ "token": self.token, "report": { "repository": repository, "sha": sha } })) | |
| 115 | + | .map_err(|error| match error { | |
| 116 | + | ureq::Error::Status(code, response) => { | |
| 117 | + | let body: Value = response.into_json().unwrap_or(Value::Null); | |
| 118 | + | body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {code}"), str::to_owned) | |
| 119 | + | } | |
| 120 | + | other => other.to_string(), | |
| 121 | + | })?; | |
| 122 | + | let mut file = std::fs::File::create(to).map_err(|error| error.to_string())?; | |
| 123 | + | std::io::copy(&mut response.into_reader(), &mut file).map_err(|error| error.to_string()) | |
| 124 | + | } | |
| 125 | + | ||
| 106 | 126 | /// An API with no address, which sends nothing: what the tests use, so | |
| 107 | 127 | /// a step's run is not held up by reports that go nowhere (each one | |
| 108 | 128 | /// tried three times, with waits between, over a second and a half). | |
| 1 | 1 | //! `uses:` steps: `actions/checkout` done natively against g1t, actions | |
| 2 | − | //! fetched from another repository on g1t (or else GitHub) and run as they are (JavaScript, composite and | |
| 3 | − | //! Docker), `docker://` images, and a few of GitHub's own whose services | |
| 4 | − | //! g1t does not have yet. | |
| 2 | + | //! fetched from another repository on g1t (or else GitHub, at the commit | |
| 3 | + | //! g1t resolved its ref to, from the archive g1t keeps of it when it has | |
| 4 | + | //! one) and run as they are (JavaScript, composite and Docker), | |
| 5 | + | //! `docker://` images, and a few of GitHub's own whose services g1t does | |
| 6 | + | //! not have yet. Every action a job's steps name is downloaded before the | |
| 7 | + | //! first step runs, as GitHub's runner does. | |
| 5 | 8 | ||
| 6 | 9 | use std::collections::BTreeMap; | |
| 7 | 10 | use std::path::{Path, PathBuf}; | |
| ⋯ | |||
| 33 | 36 | !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')) && !part.contains("..") | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 39 | + | fn is_sha(text: &str) -> bool { | |
| 40 | + | text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit()) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Whether the runner does `name` (lowercased, without its ref) itself | |
| 44 | + | /// rather than fetching it. | |
| 45 | + | fn native(name: &str, docker_hosted: bool) -> bool { | |
| 46 | + | matches!( | |
| 47 | + | name, | |
| 48 | + | "actions/checkout" | |
| 49 | + | | "actions/upload-artifact" | |
| 50 | + | | "actions/upload-artifact/merge" | |
| 51 | + | | "actions/download-artifact" | |
| 52 | + | | "actions/cache" | |
| 53 | + | | "actions/cache/restore" | |
| 54 | + | | "actions/cache/save" | |
| 55 | + | ) || (docker_hosted && name == "docker/setup-buildx-action") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// The repository and ref of an action `uses` fetches from another | |
| 59 | + | /// repository: (owner, repo, ref). None for one that is local, a Docker | |
| 60 | + | /// image, done natively, or not a name at all. | |
| 61 | + | fn remote_action(uses: &str, docker_hosted: bool) -> Option<(String, String, String)> { | |
| 62 | + | let uses = uses.trim(); | |
| 63 | + | if uses.starts_with("./") || uses.starts_with("docker://") { | |
| 64 | + | return None; | |
| 65 | + | } | |
| 66 | + | let (name, git_ref) = uses.split_once('@')?; | |
| 67 | + | if git_ref.is_empty() || native(&name.to_ascii_lowercase(), docker_hosted) { | |
| 68 | + | return None; | |
| 69 | + | } | |
| 70 | + | let mut parts = name.splitn(3, '/'); | |
| 71 | + | let (owner, repo) = (parts.next()?, parts.next()?); | |
| 72 | + | (!owner.is_empty() && !repo.is_empty()).then(|| (owner.to_owned(), repo.to_owned(), git_ref.to_owned())) | |
| 73 | + | } | |
| 74 | + | ||
| 36 | 75 | impl Job { | |
| 37 | 76 | /// Runs a git command, logging it; the credential header is never logged. | |
| 38 | 77 | fn git(&mut self, dir: &Path, args: &[&str], auth: Option<&str>) -> bool { | |
| ⋯ | |||
| 192 | 231 | } | |
| 193 | 232 | match self.log.api.action(&format!("{owner}/{repo}"), git_ref) { | |
| 194 | 233 | Ok(found) if found["source"] == "g1t" => self.fetch_g1t_action(&on_g1t, owner, repo, git_ref, &found), | |
| 195 | − | Ok(_) => self.fetch_action(owner, repo, git_ref), | |
| 234 | + | Ok(found) => { | |
| 235 | + | if let Some(note) = found["note"].as_str() { | |
| 236 | + | self.log.line(&format!("##[warning]{note}")); | |
| 237 | + | } | |
| 238 | + | let sha = found["sha"].as_str().filter(|sha| is_sha(sha)).map(str::to_owned); | |
| 239 | + | if let Some(sha) = &sha | |
| 240 | + | && found["archive"] == true | |
| 241 | + | && let Some(dir) = self.fetch_archived_action(owner, repo, git_ref, sha) | |
| 242 | + | { | |
| 243 | + | return Some(dir); | |
| 244 | + | } | |
| 245 | + | self.fetch_action(owner, repo, git_ref, sha.as_deref()) | |
| 246 | + | } | |
| 196 | 247 | Err((true, why)) => { | |
| 197 | 248 | self.log.line(&format!("##[error]{why}")); | |
| 198 | 249 | None | |
| 199 | 250 | } | |
| 200 | 251 | Err((false, why)) => { | |
| 201 | 252 | self.log.line(&format!("##[warning]g1t could not say where {owner}/{repo} is ({why}); fetching it from GitHub.")); | |
| 202 | − | self.fetch_action(owner, repo, git_ref) | |
| 253 | + | self.fetch_action(owner, repo, git_ref, None) | |
| 203 | 254 | } | |
| 204 | 255 | } | |
| 205 | 256 | } | |
| ⋯ | |||
| 235 | 286 | Some(dir.to_path_buf()) | |
| 236 | 287 | } | |
| 237 | 288 | ||
| 238 | − | /// Fetches an action from GitHub, once per job. | |
| 239 | − | fn fetch_action(&mut self, owner: &str, repo: &str, git_ref: &str) -> Option<PathBuf> { | |
| 289 | + | /// Downloads a GitHub action's archive that g1t keeps, at the commit | |
| 290 | + | /// g1t resolved its ref to. None when it could not, and the job fetches | |
| 291 | + | /// it from GitHub instead. | |
| 292 | + | fn fetch_archived_action(&mut self, owner: &str, repo: &str, git_ref: &str, sha: &str) -> Option<PathBuf> { | |
| 240 | 293 | let dir = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref); | |
| 294 | + | self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}' (SHA:{sha}) from g1t")); | |
| 295 | + | let archive = self.temp.join(format!("action{}.tar.gz", super::rand_id())); | |
| 296 | + | let downloaded = self.log.api.action_archive(&format!("{owner}/{repo}"), sha, &archive); | |
| 297 | + | let unpacked = match downloaded { | |
| 298 | + | Ok(_) => { | |
| 299 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 300 | + | let _ = std::fs::create_dir_all(&dir); | |
| 301 | + | let mut command = Command::new("tar"); | |
| 302 | + | command.arg("-xzf").arg(&archive).arg("-C").arg(&dir).arg("--strip-components=1"); | |
| 303 | + | let mut commands = Commands::default(); | |
| 304 | + | matches!(process::run(command, Duration::from_secs(300).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0))) | |
| 305 | + | } | |
| 306 | + | Err(why) => { | |
| 307 | + | self.log.line(&format!("g1t could not send it ({why}); fetching it from GitHub.")); | |
| 308 | + | false | |
| 309 | + | } | |
| 310 | + | }; | |
| 311 | + | let _ = std::fs::remove_file(&archive); | |
| 312 | + | if !unpacked { | |
| 313 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 314 | + | return None; | |
| 315 | + | } | |
| 316 | + | let _ = std::fs::write(dir.join(".g1t-fetched"), ""); | |
| 317 | + | Some(dir) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | /// Fetches an action from GitHub, once per job: at `sha` when g1t | |
| 321 | + | /// resolved its ref to one, so it is the commit g1t named. | |
| 322 | + | fn fetch_action(&mut self, owner: &str, repo: &str, git_ref: &str, sha: Option<&str>) -> Option<PathBuf> { | |
| 323 | + | let dir = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref); | |
| 241 | 324 | if dir.join(".g1t-fetched").exists() { | |
| 242 | 325 | return Some(dir); | |
| 243 | 326 | } | |
| ⋯ | |||
| 245 | 328 | self.log.line(&format!("##[error]`{owner}/{repo}@{git_ref}` is not a name g1t can fetch.")); | |
| 246 | 329 | return None; | |
| 247 | 330 | } | |
| 248 | − | self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}'")); | |
| 331 | + | match sha { | |
| 332 | + | Some(sha) => self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}' (SHA:{sha})")), | |
| 333 | + | None => self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}'")), | |
| 334 | + | } | |
| 249 | 335 | let _ = std::fs::create_dir_all(&dir); | |
| 250 | − | let url = format!("https://codeload.github.com/{owner}/{repo}/tar.gz/{git_ref}"); | |
| 336 | + | let url = format!("https://codeload.github.com/{owner}/{repo}/tar.gz/{}", sha.unwrap_or(git_ref)); | |
| 251 | 337 | let script = format!("set -o pipefail; curl -fsSL --retry 3 '{url}' | tar -xz -C '{}' --strip-components=1", dir.display()); | |
| 252 | 338 | let mut command = Command::new("bash"); | |
| 253 | 339 | command.args(["-c", &script]); | |
| ⋯ | |||
| 265 | 351 | } | |
| 266 | 352 | } | |
| 267 | 353 | ||
| 354 | + | /// Downloads every action the job's steps fetch from other | |
| 355 | + | /// repositories, before the first step runs, as GitHub's runner does in | |
| 356 | + | /// "Set up job". False when one could not be: the job fails before | |
| 357 | + | /// anything runs. Actions a composite action uses are fetched as it | |
| 358 | + | /// runs. | |
| 359 | + | pub(crate) fn prepare_actions(&mut self, steps: &[Map<String, Value>]) -> bool { | |
| 360 | + | let mut wanted: Vec<(String, String, String)> = Vec::new(); | |
| 361 | + | for step in steps { | |
| 362 | + | if let Some(found) = step.get("uses").and_then(Value::as_str).and_then(|uses| remote_action(uses, self.docker_hosted)) | |
| 363 | + | && !wanted.contains(&found) | |
| 364 | + | { | |
| 365 | + | wanted.push(found); | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | if wanted.is_empty() { | |
| 369 | + | return true; | |
| 370 | + | } | |
| 371 | + | self.log.line("Prepare all required actions"); | |
| 372 | + | let mut ready = true; | |
| 373 | + | for (owner, repo, git_ref) in &wanted { | |
| 374 | + | if self.fetch_remote_action(owner, repo, git_ref).is_none() { | |
| 375 | + | ready = false; | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | if !ready { | |
| 379 | + | self.log.line("##[error]Not every action this job uses could be downloaded, so no step was run."); | |
| 380 | + | } | |
| 381 | + | ready | |
| 382 | + | } | |
| 383 | + | ||
| 268 | 384 | /// Runs a JavaScript file of an action with Node. | |
| 269 | 385 | pub(crate) fn run_node(&mut self, action_dir: &Path, script: &str, env: &BTreeMap<String, String>) -> bool { | |
| 270 | 386 | let id = format!("node{}", super::rand_id()); | |
| ⋯ | |||
| 337 | 453 | } | |
| 338 | 454 | let (name, git_ref) = uses.split_once('@').unwrap_or((uses, "")); | |
| 339 | 455 | let lower = name.to_ascii_lowercase(); | |
| 340 | − | if lower == "docker/setup-buildx-action" && self.docker_hosted { | |
| 456 | + | if lower == "docker/setup-buildx-action" && native(&lower, self.docker_hosted) { | |
| 341 | 457 | return self.setup_buildx(with); | |
| 342 | 458 | } | |
| 343 | 459 | match lower.as_str() { | |
| ⋯ | |||
| 575 | 691 | (ok, outputs) | |
| 576 | 692 | } | |
| 577 | 693 | } | |
| 694 | + | ||
| 695 | + | #[cfg(test)] | |
| 696 | + | mod tests { | |
| 697 | + | use super::*; | |
| 698 | + | ||
| 699 | + | #[test] | |
| 700 | + | fn the_actions_fetched_before_the_first_step_are_other_repositories() { | |
| 701 | + | let found = |uses: &str| remote_action(uses, true); | |
| 702 | + | assert_eq!(found("actions/setup-node@v4"), Some(("actions".into(), "setup-node".into(), "v4".into()))); | |
| 703 | + | assert_eq!(found(" github/codeql-action/init@v3 "), Some(("github".into(), "codeql-action".into(), "v3".into()))); | |
| 704 | + | // Done by the runner itself, local, an image, or no version. | |
| 705 | + | assert_eq!(found("actions/checkout@v4"), None); | |
| 706 | + | assert_eq!(found("Actions/Cache/Restore@v4"), None); | |
| 707 | + | assert_eq!(found("docker/setup-buildx-action@v3"), None); | |
| 708 | + | assert!(remote_action("docker/setup-buildx-action@v3", false).is_some()); | |
| 709 | + | assert_eq!(found("./.g1t/actions/setup"), None); | |
| 710 | + | assert_eq!(found("docker://alpine:3"), None); | |
| 711 | + | assert_eq!(found("actions/setup-node"), None); | |
| 712 | + | assert_eq!(found("setup-node@v4"), None); | |
| 713 | + | } | |
| 714 | + | } | |
| 15 | 15 | g1t-secrets.workspace = true | |
| 16 | 16 | serde.workspace = true | |
| 17 | 17 | base64 = "0.22" | |
| 18 | + | futures-util = { version = "0.3", default-features = false, features = ["alloc"] } | |
| 18 | 19 | hex = "0.4" | |
| 19 | 20 | serde_json = { workspace = true, features = ["preserve_order"] } | |
| 20 | 21 | worker.workspace = true |
| 1 | + | -- Actions from GitHub, kept on g1t (src/registry.rs): the commit each | |
| 2 | + | -- ref last named, and the archives of commits jobs have used, in R2. | |
| 3 | + | -- Repositories are lowercased, as GitHub reads them. | |
| 4 | + | CREATE TABLE IF NOT EXISTS action_refs ( | |
| 5 | + | repository TEXT NOT NULL, | |
| 6 | + | ref TEXT NOT NULL, | |
| 7 | + | sha TEXT NOT NULL, | |
| 8 | + | resolved_at TEXT NOT NULL, | |
| 9 | + | PRIMARY KEY (repository, ref) | |
| 10 | + | ); | |
| 11 | + | ||
| 12 | + | CREATE TABLE IF NOT EXISTS action_archives ( | |
| 13 | + | repository TEXT NOT NULL, | |
| 14 | + | sha TEXT NOT NULL, | |
| 15 | + | object TEXT NOT NULL, | |
| 16 | + | size INTEGER NOT NULL, | |
| 17 | + | stored_at TEXT NOT NULL, | |
| 18 | + | used_at TEXT NOT NULL, | |
| 19 | + | PRIMARY KEY (repository, sha) | |
| 20 | + | ); | |
| 21 | + | ||
| 22 | + | CREATE INDEX IF NOT EXISTS action_archives_used ON action_archives (used_at); |
| 14 | 14 | //! environments' protection rules, holds jobs at those rules until they | |
| 15 | 15 | //! pass, and lets pull requests' runs from outside wait for approval. | |
| 16 | 16 | //! - [`cache`] lists `actions/cache` entries, which the API keeps in R2. | |
| 17 | + | //! - [`reach`] finds the actions and reusable workflows of other | |
| 18 | + | //! repositories on g1t, and [`registry`] resolves those from GitHub to | |
| 19 | + | //! commits and keeps their archives, for jobs to download from g1t. | |
| 17 | 20 | //! - [`runners`] keeps self-hosted runners, hands them jobs (and agent | |
| 18 | 21 | //! work from the runner service) when they ask, and hears back. | |
| 19 | 22 | //! | |
| ⋯ | |||
| 29 | 32 | mod plan; | |
| 30 | 33 | mod protection; | |
| 31 | 34 | mod reach; | |
| 35 | + | mod registry; | |
| 32 | 36 | mod rename; | |
| 33 | 37 | pub mod runtime; | |
| 34 | 38 | mod runners; | |
| ⋯ | |||
| 249 | 253 | "job_spec" => reply(&service.job_spec(args(body)?).await?), | |
| 250 | 254 | "job_auth" => reply(&service.job_auth(args(body)?).await?), | |
| 251 | 255 | "job_action" => reply(&service.job_action(args(body)?).await?), | |
| 256 | + | "job_action_archive" => reply(&service.job_action_archive(args(body)?).await?), | |
| 252 | 257 | "check_runs" => reply(&service.check_runs(args(body)?).await?), | |
| 253 | 258 | "job_report" => reply(&service.job_report(args(body)?).await?), | |
| 254 | 259 | // actions/cache, through the API with the job's token. | |
| 2340 | 2340 | { | |
| 2341 | 2341 | worker::console_error!("actions: the cache's sweep failed: {error}"); | |
| 2342 | 2342 | } | |
| 2343 | + | // And the archives of GitHub's actions no job uses any more. | |
| 2344 | + | if (now_ms / 60_000) % 60 == 47 | |
| 2345 | + | && let Err(error) = self.sweep_action_archives(now_ms).await | |
| 2346 | + | { | |
| 2347 | + | worker::console_error!("actions: the action archives' sweep failed: {error}"); | |
| 2348 | + | } | |
| 2343 | 2349 | // And artifacts past their time, and the toolkit's abandoned parts. | |
| 2344 | 2350 | if (now_ms / 60_000) % 60 == 37 { | |
| 2345 | 2351 | if let Err(error) = self.sweep_artifacts(now_ms).await { |
| 102 | 102 | /// `report.repository` (`owner/repo`) and `report.ref`. Answers | |
| 103 | 103 | /// `{"source": "g1t", "url", "ref", "token"}` (a read-only token for | |
| 104 | 104 | /// that repository, ending with the job, when it is private), | |
| 105 | − | /// `{"source": "github"}`, or a refusal saying why it may not be used. | |
| 105 | + | /// `{"source": "github", "sha", "archive", "note"}` (the commit its ref | |
| 106 | + | /// names, and whether g1t keeps its archive; see registry.rs), or a | |
| 107 | + | /// refusal saying why it may not be used. | |
| 106 | 108 | pub async fn job_action(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<Outcome<Value>> { | |
| 107 | 109 | let job = crate::check!(self.job_for_token(&a).await?); | |
| 108 | 110 | let Some(run) = self.run_row(&job.run_id).await? else { | |
| ⋯ | |||
| 117 | 119 | return Ok(fail(FailureCode::NotFound, "The repository is gone.")); | |
| 118 | 120 | }; | |
| 119 | 121 | let target = match crate::check!(self.find_used(&caller, &ws, &used).await?) { | |
| 120 | − | Found::GitHub => return Ok(Outcome::Ok(json!({ "source": "github" }))), | |
| 122 | + | Found::GitHub => return Ok(Outcome::Ok(self.github_action(&used).await?)), | |
| 121 | 123 | Found::G1t(target) => target, | |
| 122 | 124 | }; | |
| 123 | 125 | let full_name = format!("{}/{}", target.namespace, target.name); | |
| 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 | + | ||
| 132 | + | #[derive(Deserialize)] | |
| 133 | + | struct RefRow { | |
| 134 | + | sha: String, | |
| 135 | + | resolved_at: String, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | impl Actions { | |
| 139 | + | /// Where a job gets `used`, an action g1t does not host: | |
| 140 | + | /// `{"source": "github", "sha", "archive", "note"}`. `sha` is the commit | |
| 141 | + | /// its ref names (absent when GitHub does not know it, or never | |
| 142 | + | /// answered), `archive` whether g1t keeps that commit's archive for the | |
| 143 | + | /// job to download, and `note` anything the job should say about it. | |
| 144 | + | pub(crate) async fn github_action(&self, used: &UsesRef) -> Result<Value> { | |
| 145 | + | let repository = used.full_name().to_ascii_lowercase(); | |
| 146 | + | let now = now_ms(); | |
| 147 | + | let (sha, note) = self.resolve_github(&repository, &used.git_ref, now).await?; | |
| 148 | + | let Some(sha) = sha else { | |
| 149 | + | return Ok(json!({ "source": "github", "note": note })); | |
| 150 | + | }; | |
| 151 | + | let archive = self.keep_archive(&repository, &sha, now).await?; | |
| 152 | + | Ok(json!({ "source": "github", "sha": sha, "archive": archive, "note": note })) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// The commit `git_ref` names in GitHub's `repository`, and a note when | |
| 156 | + | /// it is the one it last named because GitHub did not answer. | |
| 157 | + | async fn resolve_github(&self, repository: &str, git_ref: &str, now: u64) -> Result<(Option<String>, Option<String>)> { | |
| 158 | + | if is_sha(git_ref) { | |
| 159 | + | return Ok((Some(git_ref.to_ascii_lowercase()), None)); | |
| 160 | + | } | |
| 161 | + | let known = self | |
| 162 | + | .db | |
| 163 | + | .prepare("SELECT sha, resolved_at FROM action_refs WHERE repository = ? AND ref = ?") | |
| 164 | + | .bind(&[repository.into(), git_ref.into()])? | |
| 165 | + | .first::<RefRow>(None) | |
| 166 | + | .await?; | |
| 167 | + | if let Some(row) = &known | |
| 168 | + | && row.resolved_at >= rfc3339(now.saturating_sub(REF_FRESH_MS)) | |
| 169 | + | { | |
| 170 | + | return Ok((Some(row.sha.clone()), None)); | |
| 171 | + | } | |
| 172 | + | let url = format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack"); | |
| 173 | + | match fetch_capped(&url, ADVERTISEMENT_MAX_BYTES).await { | |
| 174 | + | Some(advertisement) => { | |
| 175 | + | let Some(sha) = pick(&parse_advertisement(&advertisement), git_ref) else { | |
| 176 | + | return Ok((None, None)); | |
| 177 | + | }; | |
| 178 | + | self.db | |
| 179 | + | .prepare( | |
| 180 | + | "INSERT INTO action_refs (repository, ref, sha, resolved_at) VALUES (?1, ?2, ?3, ?4) | |
| 181 | + | ON CONFLICT (repository, ref) DO UPDATE SET sha = ?3, resolved_at = ?4", | |
| 182 | + | ) | |
| 183 | + | .bind(&[repository.into(), git_ref.into(), sha.as_str().into(), rfc3339(now).into()])? | |
| 184 | + | .run() | |
| 185 | + | .await?; | |
| 186 | + | Ok((Some(sha), None)) | |
| 187 | + | } | |
| 188 | + | None => Ok(match known { | |
| 189 | + | Some(row) => { | |
| 190 | + | let note = format!( | |
| 191 | + | "GitHub did not say which commit {repository}@{git_ref} is; using {}, which it named on {}.", | |
| 192 | + | &row.sha, | |
| 193 | + | row.resolved_at.get(..10).unwrap_or(&row.resolved_at) | |
| 194 | + | ); | |
| 195 | + | (Some(row.sha), Some(note)) | |
| 196 | + | } | |
| 197 | + | None => (None, None), | |
| 198 | + | }), | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// Whether g1t keeps the archive of `repository` at `sha`, fetching it | |
| 203 | + | /// from GitHub the first time. | |
| 204 | + | async fn keep_archive(&self, repository: &str, sha: &str, now: u64) -> Result<bool> { | |
| 205 | + | let Some(bucket) = &self.cache else { return Ok(false) }; | |
| 206 | + | #[derive(Deserialize)] | |
| 207 | + | struct Kept { | |
| 208 | + | used_at: String, | |
| 209 | + | } | |
| 210 | + | let kept = self | |
| 211 | + | .db | |
| 212 | + | .prepare("SELECT used_at FROM action_archives WHERE repository = ? AND sha = ?") | |
| 213 | + | .bind(&[repository.into(), sha.into()])? | |
| 214 | + | .first::<Kept>(None) | |
| 215 | + | .await?; | |
| 216 | + | if let Some(kept) = kept { | |
| 217 | + | // Marked used at most once a day: the sweep needs no more. | |
| 218 | + | if kept.used_at < rfc3339(now.saturating_sub(DAY_MS)) { | |
| 219 | + | self.db | |
| 220 | + | .prepare("UPDATE action_archives SET used_at = ? WHERE repository = ? AND sha = ?") | |
| 221 | + | .bind(&[rfc3339(now).into(), repository.into(), sha.into()])? | |
| 222 | + | .run() | |
| 223 | + | .await?; | |
| 224 | + | } | |
| 225 | + | return Ok(true); | |
| 226 | + | } | |
| 227 | + | let url = format!("https://codeload.github.com/{repository}/tar.gz/{sha}"); | |
| 228 | + | let Some(archive) = fetch_capped(&url, ARCHIVE_MAX_BYTES).await else { | |
| 229 | + | return Ok(false); | |
| 230 | + | }; | |
| 231 | + | let object = archive_object(repository, sha); | |
| 232 | + | let size = archive.len(); | |
| 233 | + | if let Err(error) = bucket.put(&object, archive).execute().await { | |
| 234 | + | worker::console_error!("actions: the archive of {repository}@{sha} was not kept: {error}"); | |
| 235 | + | return Ok(false); | |
| 236 | + | } | |
| 237 | + | let at = rfc3339(now); | |
| 238 | + | self.db | |
| 239 | + | .prepare( | |
| 240 | + | "INSERT INTO action_archives (repository, sha, object, size, stored_at, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5) | |
| 241 | + | ON CONFLICT (repository, sha) DO UPDATE SET used_at = ?5", | |
| 242 | + | ) | |
| 243 | + | .bind(&[repository.into(), sha.into(), object.into(), (size as f64).into(), at.into()])? | |
| 244 | + | .run() | |
| 245 | + | .await?; | |
| 246 | + | Ok(true) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | /// `job_action_archive`: a running job downloads an action's archive | |
| 250 | + | /// g1t keeps, by `report.repository` and `report.sha`. Answers the R2 | |
| 251 | + | /// object to send, `{"object", "size"}`. Any job may: they are public | |
| 252 | + | /// repositories' code. | |
| 253 | + | pub async fn job_action_archive(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<g1t_contracts::Outcome<Value>> { | |
| 254 | + | crate::check!(self.job_for_token(&a).await?); | |
| 255 | + | let repository = a.report["repository"].as_str().unwrap_or_default().to_ascii_lowercase(); | |
| 256 | + | let sha = a.report["sha"].as_str().unwrap_or_default().to_ascii_lowercase(); | |
| 257 | + | if !is_sha(&sha) || crate::reach::parse_uses(&format!("{repository}@{sha}")).is_none() { | |
| 258 | + | return Ok(crate::fail(g1t_contracts::FailureCode::Invalid, "Name the action's repository as owner/repo, and its commit.")); | |
| 259 | + | } | |
| 260 | + | #[derive(Deserialize)] | |
| 261 | + | struct Kept { | |
| 262 | + | object: String, | |
| 263 | + | size: f64, | |
| 264 | + | } | |
| 265 | + | let kept = self | |
| 266 | + | .db | |
| 267 | + | .prepare("SELECT object, size FROM action_archives WHERE repository = ? AND sha = ?") | |
| 268 | + | .bind(&[repository.as_str().into(), sha.as_str().into()])? | |
| 269 | + | .first::<Kept>(None) | |
| 270 | + | .await?; | |
| 271 | + | Ok(match kept { | |
| 272 | + | Some(kept) => g1t_contracts::Outcome::Ok(json!({ "object": kept.object, "size": kept.size })), | |
| 273 | + | None => crate::fail(g1t_contracts::FailureCode::NotFound, format!("g1t does not keep {repository}@{sha}.")), | |
| 274 | + | }) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | /// Hourly: archives no job has used for `ARCHIVE_UNUSED_DAYS`, and refs | |
| 278 | + | /// not looked up as long. | |
| 279 | + | pub async fn sweep_action_archives(&self, now: u64) -> Result<()> { | |
| 280 | + | let before = rfc3339(now.saturating_sub(ARCHIVE_UNUSED_DAYS * DAY_MS)); | |
| 281 | + | #[derive(Deserialize)] | |
| 282 | + | struct Gone { | |
| 283 | + | repository: String, | |
| 284 | + | sha: String, | |
| 285 | + | object: String, | |
| 286 | + | } | |
| 287 | + | let gone = self | |
| 288 | + | .db | |
| 289 | + | .prepare("SELECT repository, sha, object FROM action_archives WHERE used_at < ? LIMIT 100") | |
| 290 | + | .bind(&[before.as_str().into()])? | |
| 291 | + | .all() | |
| 292 | + | .await? | |
| 293 | + | .results::<Gone>()?; | |
| 294 | + | if let Some(bucket) = &self.cache | |
| 295 | + | && !gone.is_empty() | |
| 296 | + | { | |
| 297 | + | bucket.delete_multiple(gone.iter().map(|g| g.object.as_str()).collect()).await?; | |
| 298 | + | for archive in &gone { | |
| 299 | + | self.db | |
| 300 | + | .prepare("DELETE FROM action_archives WHERE repository = ? AND sha = ?") | |
| 301 | + | .bind(&[archive.repository.as_str().into(), archive.sha.as_str().into()])? | |
| 302 | + | .run() | |
| 303 | + | .await?; | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | self.db.prepare("DELETE FROM action_refs WHERE resolved_at < ?").bind(&[before.into()])?.run().await?; | |
| 307 | + | Ok(()) | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | #[cfg(test)] | |
| 312 | + | mod tests { | |
| 313 | + | use super::*; | |
| 314 | + | ||
| 315 | + | fn pkt(payload: &str) -> Vec<u8> { | |
| 316 | + | let mut out = format!("{:04x}", payload.len() + 4).into_bytes(); | |
| 317 | + | out.extend_from_slice(payload.as_bytes()); | |
| 318 | + | out | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | const MAIN: &str = "1111111111111111111111111111111111111111"; | |
| 322 | + | const V4_TAG: &str = "2222222222222222222222222222222222222222"; | |
| 323 | + | const V4_COMMIT: &str = "3333333333333333333333333333333333333333"; | |
| 324 | + | const V1_BRANCH: &str = "4444444444444444444444444444444444444444"; | |
| 325 | + | const V1_TAG: &str = "5555555555555555555555555555555555555555"; | |
| 326 | + | ||
| 327 | + | fn advertisement() -> Vec<u8> { | |
| 328 | + | let mut bytes = pkt("# service=git-upload-pack\n"); | |
| 329 | + | bytes.extend_from_slice(b"0000"); | |
| 330 | + | bytes.extend(pkt(&format!("{MAIN} HEAD\0multi_ack thin-pack symref=HEAD:refs/heads/main\n"))); | |
| 331 | + | bytes.extend(pkt(&format!("{MAIN} refs/heads/main\n"))); | |
| 332 | + | bytes.extend(pkt(&format!("{V1_BRANCH} refs/heads/v1\n"))); | |
| 333 | + | bytes.extend(pkt(&format!("{V1_TAG} refs/tags/v1\n"))); | |
| 334 | + | bytes.extend(pkt(&format!("{V4_TAG} refs/tags/v4\n"))); | |
| 335 | + | bytes.extend(pkt(&format!("{V4_COMMIT} refs/tags/v4^{{}}\n"))); | |
| 336 | + | bytes.extend_from_slice(b"0000"); | |
| 337 | + | bytes | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn an_advertisement_lists_its_refs_capabilities_aside() { | |
| 342 | + | let refs = parse_advertisement(&advertisement()); | |
| 343 | + | assert_eq!(refs.len(), 6); | |
| 344 | + | assert_eq!(refs[0], (MAIN.to_owned(), "HEAD".to_owned())); | |
| 345 | + | assert_eq!(refs[5], (V4_COMMIT.to_owned(), "refs/tags/v4^{}".to_owned())); | |
| 346 | + | // Cut short: what came whole is kept. | |
| 347 | + | let whole = advertisement(); | |
| 348 | + | assert_eq!(parse_advertisement(&whole[..whole.len() - 30]).len(), 5); | |
| 349 | + | assert!(parse_advertisement(b"not git").is_empty()); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[test] | |
| 353 | + | fn a_ref_is_read_as_git_reads_a_short_name() { | |
| 354 | + | let refs = parse_advertisement(&advertisement()); | |
| 355 | + | // A tag's commit, not the tag object. | |
| 356 | + | assert_eq!(pick(&refs, "v4").as_deref(), Some(V4_COMMIT)); | |
| 357 | + | // A tag before a branch of the same name. | |
| 358 | + | assert_eq!(pick(&refs, "v1").as_deref(), Some(V1_TAG)); | |
| 359 | + | assert_eq!(pick(&refs, "refs/heads/v1").as_deref(), Some(V1_BRANCH)); | |
| 360 | + | assert_eq!(pick(&refs, "main").as_deref(), Some(MAIN)); | |
| 361 | + | // A commit is itself, known or not. | |
| 362 | + | let other = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; | |
| 363 | + | assert_eq!(pick(&refs, other).as_deref(), Some("abcdef0123456789abcdef0123456789abcdef01")); | |
| 364 | + | assert_eq!(pick(&refs, "v9"), None); | |
| 365 | + | assert_eq!(pick(&refs, "abc123"), None); | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | #[test] | |
| 369 | + | fn archives_are_kept_by_repository_and_commit() { | |
| 370 | + | assert_eq!(archive_object("Actions/Checkout", V4_COMMIT), format!("action-archives/actions/checkout/{V4_COMMIT}.tar.gz")); | |
| 371 | + | assert!(is_sha(MAIN)); | |
| 372 | + | assert!(!is_sha("v4")); | |
| 373 | + | } | |
| 374 | + | } |