g1t keeps the actions workflows use from GitHub. When a job asks where an action is and g1t doesn't host it, g1t reads which commit its ref names from GitHub's advertisement (a tag before a branch, looked up again at most every five minutes, and the last commit it named while GitHub doesn't answer, which the job warns of), fetches that commit's archive once into R2, and the job downloads it from g1t; an archive over 32 MB, or one g1t can't fetch, is downloaded from GitHub by the job itself, now at the resolved commit. Jobs download every action their steps use before the first step, as GitHub's "Set up job" does, naming each commit in the log, and fail there when one can't be had. Archives unused for 30 days are swept hourly, and count toward no workspace's storage. The Actions guide says how.
- Claude-Session:
- https://claude.ai/code/session_01Ebc9fjJnLQ8p387QaFQJPt
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 | + | } |