Skip to content

Commit

`actions/cache`, `actions/cache/restore` and `actions/cache/save` are the real actions now, at the version a step names, as `setup-node` and every other action are: g1t's runner no longer stands in for them by name. They save to and restore from the repository's cache through the toolkit's cache service, which g1t already answered, so an entry `actions/cache` saves is restored by `setup-node` with the same key and paths, and the other way round, as on GitHub. Entries the stand-in saved were versioned its own way and are not restored by the real action: each cache misses once, then fills again. As for every toolkit cache, an entry between 100 and 128 MB, which the toolkit sends in one request, is not saved. The Actions guide and the limitations say so.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parentec711adBrowse files
5 files+14−3400/5 viewed
+2−2
318318 library themselves. The library refuses to run against any server but
319319 github.com. `actions/upload-artifact`, `actions/download-artifact` and
320320 `actions/upload-artifact/merge` work, as g1t runs them itself.
321−- A cache entry between 100 and 128 MB saved by an action built on the
322− toolkit, such as `setup-node` with `cache: npm`. The toolkit sends an
321+- A cache entry between 100 and 128 MB saved by `actions/cache` or another
322+ action built on the toolkit, such as `setup-node` with `cache: npm`. The toolkit sends an
323323 entry under 128 MB in one request, and g1t takes at most 100 MB in one
324324 request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and
325325 the job goes on; smaller and larger entries are saved.
+9−10
7373 | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is [the job's own token](#the-jobs-token); `GITHUB_TOKEN` is its alias. |
7474 | `environment:` on a job | The job waits for the environment's [protection rules](#environments), then reads each key's row for that environment, as environment secrets work, and the run records a [deployment](/guides/deployments-api/#deployments-from-g1t-actions) to it. `url` gives the deployment its address; `deployment: false` reads the environment's values without making one. The name may be an expression. |
7575 | `actions/upload-artifact`, `actions/download-artifact`, `actions/upload-artifact/merge` | The same inputs and outputs as version 4: `retention-days`, `overwrite`, `compression-level`, `include-hidden-files`, `!` exclusions, download by `pattern` with `merge-multiple`, and from another run with `run-id` and `github-token`. Up to 5 GiB each; see [artifacts](#artifacts). |
76−| `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository and branch, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). |
76+| `actions/cache`, `actions/cache/restore`, `actions/cache/save` | The actions themselves, at the version the step names, saving to and restoring from the repository's cache: kept per repository and branch, found by `key` or the newest under a `restore-keys` prefix. Up to 2 GiB each; see [the cache](#the-cache). |
7777 | Actions that cache through the toolkit, such as `actions/setup-node` with `cache: npm` or `Swatinem/rust-cache` | The same: they save to and restore from the repository's cache. See [actions built on the toolkit](#actions-built-on-the-toolkit). |
7878 | sccache with `SCCACHE_GHA_ENABLED` | The same: its entries go to the repository's cache. See [caching Rust builds](#caching-rust-builds). |
7979 | `permissions: id-token: write` | The job can ask for an OIDC token, and trade it for a cloud provider's credentials. See [OIDC tokens](#oidc-tokens). |
783783
784784 ## Actions built on the toolkit
785785
786−Many actions save to the cache with GitHub's toolkit, `@actions/cache`,
787−rather than through `actions/cache`: `actions/setup-node`,
788−`actions/setup-python`, `actions/setup-go` and `actions/setup-java` with
789−`cache:`, `Swatinem/rust-cache`, and others. They work on g1t as they
790−are: every job gets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_CACHE_URL` and
786+`actions/cache` and many other actions save to the cache with GitHub's
787+toolkit, `@actions/cache`: `actions/setup-node`, `actions/setup-python`,
788+`actions/setup-go` and `actions/setup-java` with `cache:`,
789+`Swatinem/rust-cache`, and others. They work on g1t as they are: every job gets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_CACHE_URL` and
791790 `ACTIONS_RESULTS_URL`, and g1t answers the toolkit's requests from the
792791 repository's cache.
793792
794793 - Their entries are the repository's, under [the cache's](#the-cache)
795794 limits, and are deleted the same way.
796−- An entry is restored only by the same kind of save: the toolkit names a
797− version for each entry, from its paths and compression. An entry
798− `setup-node` saved is not restored by `actions/cache`, and the other way
799− round.
795+- An entry is found by its key and by the version the toolkit names for
796+ it, from its paths and compression, as on GitHub: an entry `setup-node`
797+ saved is restored by `actions/cache` with the same key and paths, and
798+ the other way round.
800799 - An entry the toolkit sends whole, which it does below 128 MB, is not
801800 saved when it is over 100 MB, the most g1t takes in one request. The
802801 step warns and the job goes on.
+1−319
2323 use sha2::{Digest, Sha256};
2424
2525 use super::process::{self, Commands, Ended};
26−use super::{Job, Post, PostRun, glob, zip};
26+use super::{Job, glob, zip};
2727
28−/// The largest cache entry, compressed (`g1t_contracts::actions::CACHE_MAX_ENTRY_BYTES`).
29−const MAX_CACHE_ENTRY: u64 = 2 * 1024 * 1024 * 1024;
3028
3129 /// How often a long transfer says how far it has got.
3230 const PROGRESS_EVERY: Duration = Duration::from_secs(5);
6462 command.args(["-c", script]).current_dir(&self.workspace);
6563 let mut commands = Commands::default();
6664 matches!(process::run(command, Duration::from_secs(1800).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
67− }
68−
69− /// Downloads into `file`, as it comes; `Ok(None)` when there is
70− /// nothing there.
71− fn download(&mut self, rest: &str, file: &Path) -> Result<Option<String>, String> {
72− let response = match ureq::get(&self.url(rest)).set("authorization", &self.auth()).timeout(Duration::from_secs(1800)).call() {
73− Ok(response) => response,
74− Err(ureq::Error::Status(404, _)) => return Ok(None),
75− Err(error) => return Err(error.to_string()),
76− };
77− let matched = response.header("x-g1t-key").map(str::to_owned).unwrap_or_default();
78− let total = response.header("content-length").and_then(|n| n.parse::<u64>().ok());
79− let mut out = std::io::BufWriter::new(std::fs::File::create(file).map_err(|e| e.to_string())?);
80− self.receive(&mut response.into_reader().take(MAX_CACHE_ENTRY + 1024), total, |bytes| out.write_all(bytes))?;
81− out.flush().map_err(|e| e.to_string())?;
82− Ok(Some(matched))
8365 }
8466
8567 /// Reads a download to its end, handing `each` every piece, and says
10587 self.log.tick();
10688 }
10789 Ok(received)
108− }
109−
110− /// Saves `file` as the cache entry `key`, in parts. `Ok(false)` when
111− /// the key is already cached.
112− fn upload_cache(&mut self, key: &str, version: &str, file: &Path) -> Result<bool, String> {
113− let size = std::fs::metadata(file).map_err(|e| e.to_string())?.len();
114− if size > MAX_CACHE_ENTRY {
115− return Err(format!("it is {} MB, more than a cache entry may be ({} MB)", size / 1_048_576, MAX_CACHE_ENTRY / 1_048_576));
116− }
117− let started = match ureq::post(&self.url(&format!("cache/uploads?key={}&size={size}&version={}", urlencode(key), urlencode(version))))
118− .set("authorization", &self.auth())
119− .timeout(Duration::from_secs(60))
120− .call()
121− {
122− Ok(response) => response.into_json::<serde_json::Value>().map_err(|e| e.to_string())?,
123− Err(ureq::Error::Status(409, _)) => return Ok(false),
124− Err(ureq::Error::Status(_, response)) => return Err(refusal(response)),
125− Err(error) => return Err(error.to_string()),
126− };
127− let id = started["id"].as_str().unwrap_or_default().to_owned();
128− let upload = started["upload"].as_str().unwrap_or_default().to_owned();
129− let part_bytes = started["part_bytes"].as_u64().filter(|n| *n > 0).unwrap_or(32 * 1024 * 1024);
130− let base = format!("cache/uploads/{}", urlencode(&id));
131− let sent = self.send_parts(&base, &upload, file, part_bytes);
132− let parts = match sent {
133− Ok(parts) => parts,
134− Err(error) => {
135− let _ = ureq::delete(&self.url(&format!("{base}?upload={}", urlencode(&upload)))).set("authorization", &self.auth()).call();
136− return Err(error);
137− }
138− };
139− ureq::post(&self.url(&format!("{base}/complete?upload={}", urlencode(&upload))))
140− .set("authorization", &self.auth())
141− .timeout(Duration::from_secs(120))
142− .send_json(serde_json::json!({ "size": size, "parts": parts }))
143− .map_err(|e| match e {
144− ureq::Error::Status(_, response) => refusal(response),
145− other => other.to_string(),
146− })?;
147− Ok(true)
14890 }
14991
15092 /// Sends `file` in parts of `part_bytes`; each part's number and etag.
567509 }
568510 }
569511 result
570− }
571−
572− /// The cache's `path`, each made absolute (`~/` is the home folder,
573− /// anything else is under the workspace), `!` patterns kept as they
574− /// came, with their `!`.
575− fn cache_paths(&self, with: &BTreeMap<String, String>) -> Vec<String> {
576− let home = self.base_env_value("HOME").unwrap_or_else(|| "/home/node".into());
577− let workspace = self.workspace.display().to_string();
578− cache_patterns(with.get("path").map(String::as_str).unwrap_or_default(), &home, &workspace)
579− }
580−
581− /// `actions/cache` and `actions/cache/restore`: restores what it can,
582− /// and for `actions/cache`, saves at the end of the job on a miss.
583− pub(crate) fn cache(&mut self, with: &BTreeMap<String, String>, save_after: bool, title: &str) -> (bool, BTreeMap<String, String>) {
584− let key = with.get("key").cloned().unwrap_or_default();
585− if key.is_empty() {
586− self.log.line("##[error]The cache needs a `key`.");
587− return (false, BTreeMap::new());
588− }
589− let paths = self.cache_paths(with);
590− let version = cache_version(with.get("path").map(String::as_str).unwrap_or_default(), compression());
591− let restore = lines(with.get("restore-keys").map(String::as_str).unwrap_or_default());
592− let query = format!(
593− "cache?key={}&restore={}&version={}",
594− urlencode(&key),
595− urlencode(&restore.join("\n")),
596− urlencode(&version)
597− );
598− let archive = self.temp.join("cache-restore.tar");
599− let started = Instant::now();
600− let mut outputs = BTreeMap::new();
601− let exact = match self.download(&query, &archive) {
602− Ok(Some(matched)) => {
603− let lookup_only = with.get("lookup-only").is_some_and(|v| v == "true");
604− let size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
605− // tar finds out from the archive whether it is zstd or gzip.
606− if !lookup_only && !self.shell(&format!("tar{} -xPf {}", tar_local(), quote(&archive.display().to_string()))) {
607− self.log.line("##[warning]The cache was found but could not be unpacked.");
608− }
609− let _ = std::fs::remove_file(&archive);
610− self.log.line(&format!("Cache restored from key: {matched} ({} in {:.1}s)", megabytes(size), started.elapsed().as_secs_f64()));
611− outputs.insert("cache-matched-key".into(), matched.clone());
612− matched == key
613− }
614− Ok(None) => {
615− self.log.line(&format!("Cache not found for input keys: {}", std::iter::once(key.clone()).chain(restore).collect::<Vec<_>>().join(", ")));
616− if with.get("fail-on-cache-miss").is_some_and(|v| v == "true") {
617− self.log.line("##[error]The cache missed, and `fail-on-cache-miss` is set.");
618− return (false, outputs);
619− }
620− false
621− }
622− Err(error) => {
623− self.log.line(&format!("##[warning]The cache could not be read: {error}"));
624− false
625− }
626− };
627− outputs.insert("cache-hit".into(), exact.to_string());
628− outputs.insert("cache-primary-key".into(), key.clone());
629− if save_after && !exact {
630− self.posts.push(Post {
631− name: format!("Post {title}"),
632− condition: "success()".into(),
633− env: BTreeMap::new(),
634− run: PostRun::CacheSave { key, paths, version },
635− });
636− }
637− (true, outputs)
638− }
639−
640− /// Saves paths under a key, unless the key is taken.
641− pub(crate) fn cache_save(&mut self, key: &str, paths: &[String], version: &str) -> bool {
642− if paths.iter().all(|p| p.starts_with('!')) {
643− self.log.line("##[warning]Nothing to cache: no `path`.");
644− return true;
645− }
646− let archive = self.temp.join("cache-save.tar");
647− let started = Instant::now();
648− match self.run_shell(&pack_script(paths, &archive.display().to_string())) {
649− Some(0) => {}
650− Some(3) => {
651− self.log.line("##[warning]None of the cache's paths exist; nothing was saved.");
652− return true;
653− }
654− _ => {
655− self.log.line("##[warning]The cache could not be packed; nothing was saved.");
656− return true;
657− }
658− }
659− let size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
660− let packed = started.elapsed().as_secs_f64();
661− match self.upload_cache(key, version, &archive) {
662− Ok(true) => self.log.line(&format!(
663− "Cache saved with key: {key} ({}, packed in {packed:.1}s, sent in {:.1}s)",
664− megabytes(size),
665− started.elapsed().as_secs_f64() - packed
666− )),
667− Ok(false) => self.log.line(&format!("Cache not saved: {key} is already cached.")),
668− // A cache that cannot be saved does not fail the job, as on GitHub.
669− Err(error) => self.log.line(&format!("##[warning]The cache could not be saved: {error}")),
670− }
671− let _ = std::fs::remove_file(&archive);
672− true
673− }
674−
675− /// Runs a shell line, logging its output; its exit code.
676− fn run_shell(&mut self, script: &str) -> Option<i32> {
677− let mut command = Command::new("bash");
678− command.args(["-c", script]).current_dir(&self.workspace);
679− let mut commands = Commands::default();
680− match process::run(command, Duration::from_secs(1800).min(self.remaining_time()), &mut self.log, &mut commands) {
681− Ok(Ended::Exited(code)) => Some(code),
682− _ => None,
683− }
684512 }
685513
686− /// `actions/cache/save`.
687− pub(crate) fn cache_save_now(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
688− let key = with.get("key").cloned().unwrap_or_default();
689− let paths = self.cache_paths(with);
690− let version = cache_version(with.get("path").map(String::as_str).unwrap_or_default(), compression());
691− (self.cache_save(&key, &paths, &version), BTreeMap::new())
692− }
693514 }
694515
695516 /// An input, trimmed; `None` when empty.
868689
869690 fn megabytes(bytes: u64) -> String {
870691 if bytes < 1_048_576 { format!("{} KB", bytes.div_ceil(1024)) } else { format!("{:.1} MB", bytes as f64 / 1_048_576.0) }
871−}
872−
873−/// How this machine compresses cache entries: zstd where it has it, else
874−/// gzip, as `pack_script` decides.
875−fn compression() -> &'static str {
876− let zstd = Command::new("sh").args(["-c", "command -v zstd"]).output().is_ok_and(|out| out.status.success());
877− if zstd { "zstd" } else { "gzip" }
878−}
879−
880−/// An entry's version: the hash of its `path` lines, as written, and its
881−/// compression, as `actions/cache` makes one. The same key saved for other
882−/// paths, or packed another way, is another entry.
883−fn cache_version(path: &str, compression: &str) -> String {
884− use sha2::{Digest, Sha256};
885− let mut parts = lines(path);
886− parts.push(compression.to_owned());
887− hex::encode(Sha256::digest(parts.join("|").as_bytes()))
888−}
889−
890−/// The lines of a cache's `path`, absolute: `~/` is `home`, a relative
891−/// path is under `workspace`. A `!` pattern keeps its `!`.
892−fn cache_patterns(path: &str, home: &str, workspace: &str) -> Vec<String> {
893− lines(path)
894− .into_iter()
895− .map(|line| {
896− let (bang, p) = match line.strip_prefix('!') {
897− Some(rest) => ("!", rest.trim().to_owned()),
898− None => ("", line),
899− };
900− let p = if p == "~" {
901− home.to_owned()
902− } else if let Some(rest) = p.strip_prefix("~/") {
903− format!("{home}/{rest}")
904− } else {
905− p
906− };
907− let p = if p.starts_with('/') { p } else { format!("{}/{}", workspace.trim_end_matches('/'), p.trim_start_matches("./")) };
908− format!("{bang}{}", p.trim_end_matches('/'))
909− })
910− .collect()
911−}
912−
913−/// A path pattern as a word bash expands as a glob: everything but `*`,
914−/// `?` and `[...]` escaped, so spaces and quotes stay literal.
915−fn glob_word(pattern: &str) -> String {
916− let mut out = String::new();
917− for c in pattern.chars() {
918− if c.is_ascii_alphanumeric() || matches!(c, '*' | '?' | '[' | ']' | '/' | '.' | '-' | '_' | '~' | '+' | ',' | '=' | '@' | ':') {
919− out.push(c);
920− } else {
921− out.push('\\');
922− out.push(c);
923− }
924− }
925− out
926−}
927−
928−/// The script that packs a cache: its patterns expanded (`**` reaching
929−/// any depth), `!` patterns left out, into a tar archive compressed with
930−/// zstd where there is one, else gzip. Exits 3 when nothing matched.
931−fn pack_script(patterns: &[String], archive: &str) -> String {
932− let includes: Vec<String> = patterns.iter().filter(|p| !p.starts_with('!')).map(|p| glob_word(p)).collect();
933− let excludes: Vec<String> = patterns
934− .iter()
935− .filter_map(|p| p.strip_prefix('!'))
936− // tar's patterns: `*` already crosses `/`, so `**` is the same.
937− .map(|p| format!("--exclude={}", quote(&p.replace("**", "*"))))
938− .collect();
939− format!(
940− "set -o pipefail; shopt -s globstar nullglob dotglob; found=( {} ); files=(); \
941− for f in \"${{found[@]}}\"; do if [ -e \"$f\" ]; then files+=(\"$f\"); fi; done; \
942− if [ ${{#files[@]}} -eq 0 ]; then exit 3; fi; \
943− if command -v zstd >/dev/null; then compress='zstd -T0 -3'; else compress=gzip; fi; \
944− tar{} -cPf {} -I \"$compress\" {} -- \"${{files[@]}}\"",
945− includes.join(" "),
946− tar_local(),
947− quote(archive),
948− excludes.join(" ")
949− )
950692 }
951693
952694 /// GNU tar on Windows reads `C:...` as a host and a file; `--force-local`
975717 #[cfg(test)]
976718 mod tests {
977719 use super::*;
978−
979− #[test]
980− fn a_cache_entrys_version_follows_its_paths_and_compression() {
981− let version = cache_version("~/.cargo/registry\ntarget", "zstd");
982− assert_eq!(version.len(), 64);
983− assert_eq!(version, cache_version("~/.cargo/registry\n\ntarget\n", "zstd"), "blank lines do not count");
984− assert_ne!(version, cache_version("~/.cargo/registry", "zstd"));
985− assert_ne!(version, cache_version("~/.cargo/registry\ntarget", "gzip"));
986− }
987−
988− #[test]
989− fn cache_paths_take_home_globs_and_exclusions() {
990− let paths = cache_patterns("~/.cargo/registry/cache\ntarget/*/release/\n!target/**/incremental\n./dist\n/abs/x\n~", "/home/node", "/w/repo/");
991− assert_eq!(
992− paths,
993− ["/home/node/.cargo/registry/cache", "/w/repo/target/*/release", "!/w/repo/target/**/incremental", "/w/repo/dist", "/abs/x", "/home/node"]
994− );
995− assert_eq!(glob_word("/w/my repo/target/**/*.rlib"), "/w/my\\ repo/target/**/*.rlib");
996− assert_eq!(glob_word("/w/a'b"), "/w/a\\'b");
997− }
998−
999− #[test]
1000− fn packing_keeps_windows_paths_local() {
1001− let script = pack_script(&["C:/w/target".into()], "C:/t/c.tar");
1002− assert_eq!(script.contains("--force-local"), cfg!(windows), "{script}");
1003− }
1004−
1005− #[test]
1006− fn packing_expands_globs_and_leaves_exclusions_out() {
1007− let script = pack_script(&["/w/target/*/release".into(), "!/w/target/**/incremental".into()], "/t/c.tar");
1008− assert!(script.contains("found=( /w/target/*/release )"), "{script}");
1009− assert!(script.contains("--exclude='/w/target/*/incremental'"), "{script}");
1010− assert!(script.contains("zstd -T0"), "{script}");
1011− assert!(script.contains("exit 3"), "{script}");
1012− }
1013−
1014− /// Packs and unpacks for real, where bash and tar are (not on Windows).
1015− #[test]
1016− #[cfg(unix)]
1017− fn a_packed_cache_unpacks_without_what_was_left_out() {
1018− let dir = std::env::temp_dir().join(format!("g1t-cache-test-{}", std::process::id()));
1019− let _ = std::fs::remove_dir_all(&dir);
1020− for file in ["target/release/deps/a.rlib", "target/release/incremental/x.bin", "target/wasm/release/deps/b.rlib", "src/main.rs"] {
1021− let path = dir.join(file);
1022− std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1023− std::fs::write(&path, file).unwrap();
1024− }
1025− let root = dir.display().to_string();
1026− let patterns = cache_patterns("target/**/deps\ntarget/release/incremental\n!target/**/incremental", "/home/node", &root);
1027− let archive = dir.join("c.tar").display().to_string();
1028− let status = Command::new("bash").args(["-c", &pack_script(&patterns, &archive)]).status().unwrap();
1029− assert!(status.success());
1030− let listed = Command::new("tar").args(["-tPf", &archive]).output().unwrap();
1031− let listed = String::from_utf8_lossy(&listed.stdout);
1032− assert!(listed.contains("deps/a.rlib") && listed.contains("deps/b.rlib"), "{listed}");
1033− assert!(!listed.contains("incremental") && !listed.contains("main.rs"), "{listed}");
1034− let none = Command::new("bash").args(["-c", &pack_script(&[format!("{root}/nothing/*")], &archive)]).status().unwrap();
1035− assert_eq!(none.code(), Some(3));
1036− let _ = std::fs::remove_dir_all(&dir);
1037− }
1038720
1039721 #[test]
1040722 fn transfers_say_how_far_they_have_got_as_githubs_do() {
+0−2
6060
6161 pub(crate) enum PostRun {
6262 Node { action_dir: PathBuf, script: String, using: String },
63− CacheSave { key: String, paths: Vec<String>, version: String },
6463 /// A Docker action's `post-entrypoint`.
6564 Docker(containers::DockerRun),
6665 }
11481147 job.log.step_state(number, &post.name, "in_progress", None);
11491148 let ok = match &post.run {
11501149 PostRun::Node { action_dir, script, using } => job.run_node(action_dir, script, &post.env, using),
1151− PostRun::CacheSave { key, paths, version } => job.cache_save(key, paths, version),
11521150 PostRun::Docker(run) => job.run_docker(run).0,
11531151 };
11541152 job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" }));
+2−7
4949 | "actions/upload-artifact"
5050 | "actions/upload-artifact/merge"
5151 | "actions/download-artifact"
52− | "actions/cache"
53− | "actions/cache/restore"
54− | "actions/cache/save"
5552 ) || (docker_hosted && name == "docker/setup-buildx-action")
5653 }
5754
482479 "actions/upload-artifact" => return self.upload_artifact(with),
483480 "actions/upload-artifact/merge" => return self.merge_artifacts(with),
484481 "actions/download-artifact" => return self.download_artifact(with),
485− "actions/cache" => return self.cache(with, true, title),
486− "actions/cache/restore" => return self.cache(with, false, title),
487− "actions/cache/save" => return self.cache_save_now(with),
488482 _ => {}
489483 }
490484 let Some(found) = self.resolve_action(uses) else {
885879 assert_eq!(found(" github/codeql-action/init@v3 "), Some(("github".into(), "codeql-action".into(), "v3".into())));
886880 // Done by the runner itself, local, an image, or no version.
887881 assert_eq!(found("actions/checkout@v4"), None);
888− assert_eq!(found("Actions/Cache/Restore@v4"), None);
882+ // actions/cache is the real action, fetched like any other.
883+ assert_eq!(found("Actions/Cache/Restore@v4"), Some(("Actions".into(), "Cache".into(), "v4".into())));
889884 assert_eq!(found("docker/setup-buildx-action@v3"), None);
890885 assert!(remote_action("docker/setup-buildx-action@v3", false).is_some());
891886 assert_eq!(found("./.g1t/actions/setup"), None);