Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
161 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

g1t's own workflows cache by one scheme: main saves, pull requests and release tags restore, and Rust caches are keyed by the toolchain and the dependencies rather than by Cargo.lock. Measured over the last two days of runs: 25 of the 31 changes to Cargo.lock in a week were a release's version bump, and each saved every Rust cache again; CI's test build, restored from older entries and saved again each time, grew from 671 MB to 1,687 MB in a day (its restore from 15 s to a minute, toward the 2 GB an entry may hold), and with its superseded copies filled the repository's 10 GiB; release tags never read another tag's entries, so 10 of 14 desktop Linux builds, 12 of 13 Windows builds and most runner builds found nothing. Now scripts/deploy/ci-cache.mjs gives each job rustc's version and a hash of Cargo.lock's crates.io and git packages, and prunes a restored Cargo target to what the build used before it is saved; CI's tests build with line tables only; CI's Rust job fetches every crate and alone saves them, with the index; the runner's image build keeps target/release, where its build scripts and proc macros go (87 crates were compiled again on every deploy); apt's downloads for Tauri are cached (scripts/apt-install.sh, 110 s of every desktop job); cargo-xwin is pinned and kept with the MSVC CRT. The new Caches workflow looks every key up on each push to main and builds only what is missing: npm's downloads (moved from Deploy), the runner release per target and the desktop release's Linux and Windows builds, which the release workflows now only restore. Tests hold every entry to one set of paths across workflows, every restored entry to a saver on main, releases to restoring only, Rust keys to the toolchain and dependencies, and pruning before saving; CONTRIBUTING.md says how it fits together, and the Actions guide says how a tag's run restores and that a lookup counts as a use.1// The keys of the Rust caches in g1t's own workflows, and the pruning of a
2// Cargo target before it is saved. CONTRIBUTING.md ("How g1t's own CI
3// caches") has the whole scheme.
4//
5// node scripts/deploy/ci-cache.mjs keys
6// node scripts/deploy/ci-cache.mjs prune <profile dir>... -- <cargo args>
7//
8// keys writes to the step's outputs (GITHUB_OUTPUT):
9//
10// rust rustc's version, such as 1.99.0. Everything Cargo compiles
11// depends on it, so a new toolchain starts a new line of entries
12// rather than restoring the old one's, whose files it would
13// only carry along unused.
14// cargo a hash of the packages Cargo.lock takes from crates.io and git,
15// leaving out the workspace's own: a release bumps a crate's
16// version, which changes Cargo.lock and nothing that is cached.
17// Keyed by the whole file, 25 of the 31 changes to Cargo.lock in
18// the week before this were version bumps, and each saved every
19// Rust cache again (the test build's 0.7 to 1.7 GB each time).
20// desktop the same for apps/desktop/src-tauri/Cargo.lock.
21//
22// prune runs Cargo again with --message-format=json once the build is done
23// (everything is fresh, so it only lists what it built) and deletes, from
24// each profile dir's deps/, build/ and .fingerprint/, the files and folders
25// of units this build did not use. A target restored from an older entry
26// (a restore key) keeps the old versions of whatever changed beside the new
27// ones, and saving it carried them on forever: CI's test build grew from
28// 671 MB to 1,687 MB in a day, two thirds of the way to the 2 GB an entry
29// may hold, and its restore from 15 s to a minute. Anything unexpected
30// (Cargo fails, lists nothing) prunes nothing.
31
32import { spawnSync } from "node:child_process";
33import { createHash } from "node:crypto";
34import { appendFileSync, existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
35import { basename, join } from "node:path";
36import { fileURLToPath } from "node:url";
37
38const ROOT = fileURLToPath(new URL("../..", import.meta.url));
39
40/** The `[[package]]` blocks of a Cargo.lock that come from a registry or git: not the workspace's own. */
41export function externalPackages(lock) {
42 return lock
43 .split(/^\[\[package\]\]\s*$/m)
44 .slice(1)
45 .map((block) => block.trim())
46 .filter((block) => /^source = /m.test(block))
47 .sort();
48}
49
50/** 16 hex characters of the SHA-256 of a Cargo.lock's external packages. */
51export function depsHash(lock) {
52 return createHash("sha256").update(externalPackages(lock).join("\n\n")).digest("hex").slice(0, 16);
53}
54
55/** `1.99.0` from `rustc 1.99.0 (90b35a623 2026-09-30)`; null if it is not that. */
56export function rustVersion(text) {
57 return text.match(/^rustc (\S+)/)?.[1] ?? null;
58}
59
60function keys() {
61 const rustc = spawnSync("rustc", ["-V"], { encoding: "utf8" });
62 const rust = rustVersion(rustc.stdout ?? "");
63 if (!rust) throw new Error(`rustc -V said ${JSON.stringify(rustc.stdout ?? rustc.error?.message)}`);
64 const outputs = { rust };
65 for (const [name, path] of [
66 ["cargo", "Cargo.lock"],
67 ["desktop", "apps/desktop/src-tauri/Cargo.lock"],
68 ]) {
69 const file = join(ROOT, path);
70 if (existsSync(file)) outputs[name] = depsHash(readFileSync(file, "utf8"));
71 }
72 const lines = Object.entries(outputs).map(([k, v]) => `${k}=${v}`);
73 console.log(lines.join("\n"));
74 if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
75}
76
77/** The 16-hex-character hash Cargo puts on a unit's files and folders (`libserde-0123456789abcdef.rlib`), or null. */
78export function unitHash(name) {
79 return name.match(/-([0-9a-f]{16})(?:\.[^/]*)?$/)?.[1] ?? null;
80}
81
82/** The hashes of every unit in Cargo's JSON messages: its files, and a build script's out dir. */
83export function usedHashes(messages) {
84 const used = new Set();
85 for (const line of messages.split("\n")) {
86 if (!line.startsWith("{")) continue;
87 let m;
88 try {
89 m = JSON.parse(line);
90 } catch {
91 continue;
92 }
93 const paths = [...(m.filenames ?? []), ...(m.executable ? [m.executable] : [])];
94 if (m.reason === "build-script-executed" && m.out_dir) paths.push(m.out_dir.replace(/[\\/]out$/, ""));
95 for (const path of paths) {
96 // A unit's own folder (build/foo-<hash>/build-script-build) or file (deps/libfoo-<hash>.rlib).
97 for (const part of path.split(/[\\/]/).slice(-2)) {
98 const hash = unitHash(part);
99 if (hash) used.add(hash);
100 }
101 }
102 }
103 return used;
104}
105
106/** What to delete from a profile dir: deps/ files and build/, .fingerprint/ folders of units not in `used`. */
107export function stale(dir, used) {
108 const out = [];
109 for (const sub of ["deps", "build", ".fingerprint"]) {
110 const at = join(dir, sub);
111 if (!existsSync(at)) continue;
112 for (const name of readdirSync(at)) {
113 const hash = unitHash(name);
114 if (hash && !used.has(hash)) out.push(join(at, name));
115 }
116 }
117 return out;
118}
119
120function bytes(path) {
121 const s = statSync(path, { throwIfNoEntry: false });
122 if (!s) return 0;
123 if (!s.isDirectory()) return s.size;
124 return readdirSync(path).reduce((sum, name) => sum + bytes(join(path, name)), 0);
125}
126
127function prune(args) {
128 const split = args.indexOf("--");
129 const dirs = split < 0 ? [] : args.slice(0, split);
130 const cargo = split < 0 ? [] : args.slice(split + 1);
131 if (!dirs.length || !cargo.length) throw new Error("usage: ci-cache.mjs prune <profile dir>... -- <cargo args>");
132 const listed = spawnSync("cargo", [...cargo, "--message-format=json"], {
133 cwd: ROOT,
134 encoding: "utf8",
135 maxBuffer: 1 << 30,
136 stdio: ["ignore", "pipe", "inherit"],
137 });
138 const used = listed.status === 0 ? usedHashes(listed.stdout) : new Set();
139 if (!used.size) {
140 console.log(`::warning title=Cargo cache::cargo ${cargo.join(" ")} did not list what it built, so nothing is pruned`);
141 return;
142 }
143 for (const dir of dirs) {
144 const before = bytes(join(ROOT, dir));
145 const gone = stale(join(ROOT, dir), used);
146 for (const path of gone) rmSync(path, { recursive: true, force: true });
147 const after = bytes(join(ROOT, dir));
148 const mb = (n) => `${Math.round(n / 1_048_576)} MB`;
149 console.log(`${dir}: ${gone.length} files and folders of units this build did not use, ${mb(before - after)}; ${mb(after)} left`);
150 }
151}
152
153if (process.argv[1] && basename(process.argv[1]) === "ci-cache.mjs") {
154 const [command, ...rest] = process.argv.slice(2);
155 if (command === "keys") keys();
156 else if (command === "prune") prune(rest);
157 else {
158 console.error("usage: node scripts/deploy/ci-cache.mjs keys | prune <profile dir>... -- <cargo args>");
159 process.exit(2);
160 }
161}