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.

Commit

g1t's own CI runs its light jobs on the 2-core machine, and a pull request skips the desktop check and the site build when it doesn't touch them

Since ubuntu-latest became the 4-core machine in this public repository (#169), every job ran at $0.007 a minute, jobs that build nothing included. - Jobs that build nothing name g1t-2core ($0.004 a minute): Deploy's check, plan, migrations and smoke test; Caches' look and npm; Cache tidy; and the publish jobs of the plugin, action, desktop, CLI and runner releases. Builds, tests and Deploy's stages stay on ubuntu-latest (4 vCPU). Conformance stays on ubuntu-latest, being the same file GitHub runs. - `ci-affected.mjs --job desktop|build` says whether a pull request needs the job. Desktop runs for the app's folder, its release scripts and the build's own files (DESKTOP). Build (the site, sudo and the docs) runs for anything but a change of Rust source alone. A job that isn't needed passes without running, so CI still reports every job. TypeScript runs on every pull request, because its tests and the conformance suite read Rust source and manifests throughout. main's runs run everything, as before. - The prune check in repository_workflows.rs accepts the desktop job's gate before its cache-hit condition. Measured over 100 runs today: Rust, Deploy's builds and TypeScript are ~70% of the minutes and need the 4-core machine; the light jobs were ~8%, and Desktop and Build ~16%. Checked: node --test scripts/deploy/ci-affected.test.mjs; cargo test -p g1t-actions --test repository_workflows; prettier; git diff --check. Closes #342

G1t-Session:
ses_01m5gahajbescr7pk21fqg3y0q
syntaqxcommitted Parent4eeb771Browse files
12 files+154−210/12 viewed
+1−1
5353 jobs:
5454 publish:
5555 name: Publish g1t/<action>
56− runs-on: ubuntu-latest
56+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
5757 timeout-minutes: 15
5858 steps:
5959 - uses: actions/checkout@v5
+1−1
2424 jobs:
2525 tidy:
2626 name: Replaced entries
27− runs-on: ubuntu-latest
27+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
2828 timeout-minutes: 10
2929 steps:
3030 - uses: actions/checkout@v5
+2−2
4646 jobs:
4747 look:
4848 name: What is missing
49− runs-on: ubuntu-latest
49+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
5050 timeout-minutes: 10
5151 outputs:
5252 npm: ${{ steps.npm.outputs.cache-hit != 'true' }}
174174 name: npm's downloads
175175 needs: look
176176 if: ${{ needs.look.outputs.npm == 'true' }}
177− runs-on: ubuntu-latest
177+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
178178 timeout-minutes: 20
179179 steps:
180180 - uses: actions/checkout@v5
+32−3
77 # typescript type checks and tests of the apps and TS services, the
88 # deploy and ops scripts, and the deploy manifest
99 # build the site, sudo and the docs build as they deploy
10+# desktop the desktop app's checks and tests, and the release scripts'
11+#
12+# On a pull request, `rust`, `build` and `desktop` run only for what it
13+# changes (scripts/deploy/ci-affected.mjs) and pass without running
14+# otherwise; `typescript` always runs.
1015 #
1116 # On a push to main `rust`, `typescript` and `desktop` run, keeping main's
1217 # caches current:
230235 runs-on: ubuntu-latest
231236 timeout-minutes: 30
232237 steps:
238+ # Two commits deep, as for the Rust parts: what the pull request
239+ # changes. A change of only Rust source, which none of the three
240+ # reads, passes without building (ci-affected.mjs JOBS).
233241 - uses: actions/checkout@v5
242+ with:
243+ fetch-depth: 2
244+ - name: Whether this pull request needs it
245+ id: affected
246+ run: node scripts/deploy/ci-affected.mjs --job build
234247 # npm's downloads, kept between runs: an install reads them first
235248 # and asks the registry only for what changed, so a dropped
236249 # connection to it rarely stops the job.
237250 - name: Cache npm's downloads
251+ if: ${{ steps.affected.outputs.run == 'true' }}
238252 uses: actions/cache@v4
239253 with:
240254 path: ~/.npm
241255 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
242256 restore-keys: npm-full-${{ runner.os }}-
243257 - name: Install
258+ if: ${{ steps.affected.outputs.run == 'true' }}
244259 run: node scripts/deploy/npm-ci.mjs --prefer-offline --no-audit --no-fund
245260 - name: The site, sudo and the docs
261+ if: ${{ steps.affected.outputs.run == 'true' }}
246262 run: node scripts/deploy.mjs build --only web,sudo,docs
247263
248264 desktop:
252268 runs-on: ubuntu-latest
253269 timeout-minutes: 30
254270 steps:
271+ # A pull request that changes none of the app's files, its release
272+ # scripts or the build's own (ci-affected.mjs DESKTOP) passes without
273+ # building; main's runs build it, keeping its cache.
255274 - uses: actions/checkout@v5
275+ with:
276+ fetch-depth: 2
277+ - name: Whether this pull request needs it
278+ id: affected
279+ run: node scripts/deploy/ci-affected.mjs --job desktop
256280 - name: Cache keys
257281 id: keys
282+ if: ${{ steps.affected.outputs.run == 'true' }}
258283 run: node scripts/deploy/ci-cache.mjs keys
259284 # g1t's runner image has Tauri's system packages, so on it the two
260285 # steps after this are skipped (they took 68 s a run: a minute to
262287 # them (self-hosted, or an older image) installs them as before.
263288 - name: Is what Tauri needs on Linux installed
264289 id: tauri-apt
290+ if: ${{ steps.affected.outputs.run == 'true' }}
265291 run: bash scripts/apt-install.sh tauri --check
266292 # apt's lists and downloads (scripts/apt-install.sh): 110 s of every
267293 # run before, mostly fetching 335 MB. Saved here, on main; the desktop
268294 # release's Linux build restores the same entry.
269295 - name: Cache apt's downloads
270− if: ${{ steps.tauri-apt.outputs.installed != 'true' }}
296+ if: ${{ steps.affected.outputs.run == 'true' && steps.tauri-apt.outputs.installed != 'true' }}
271297 uses: actions/cache@v4
272298 with:
273299 path: ~/.cache/apt/tauri
274300 key: apt-tauri-${{ runner.os }}-${{ hashFiles('scripts/apt-install.sh') }}
275301 - name: What Tauri needs on Linux
276− if: ${{ steps.tauri-apt.outputs.installed != 'true' }}
302+ if: ${{ steps.affected.outputs.run == 'true' && steps.tauri-apt.outputs.installed != 'true' }}
277303 run: bash scripts/apt-install.sh tauri
278304 - name: Cache the desktop app's build
279305 id: desktop-build
306+ if: ${{ steps.affected.outputs.run == 'true' }}
280307 uses: actions/cache@v4
281308 with:
282309 path: |
289316 # Outside the workspace on purpose (Cargo.toml says why): checked and
290317 # tested here, built into installers by desktop-release.yml.
291318 - name: Check and test
319+ if: ${{ steps.affected.outputs.run == 'true' }}
292320 run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --quiet
293321 - name: Only this build's files are saved
294− if: ${{ steps.desktop-build.outputs.cache-hit != 'true' }}
322+ if: ${{ steps.affected.outputs.run == 'true' && steps.desktop-build.outputs.cache-hit != 'true' }}
295323 run: node scripts/deploy/ci-cache.mjs prune apps/desktop/src-tauri/target/debug -- test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --no-run
296324 - name: The release scripts' tests
325+ if: ${{ steps.affected.outputs.run == 'true' }}
297326 run: node --test scripts/desktop-release.test.mjs scripts/mobile-release.test.mjs scripts/cli-release.test.mjs
+1−1
8585 publish:
8686 name: Sign and publish
8787 needs: build
88− runs-on: ubuntu-latest
88+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
8989 # Production's secrets, without a deployment: a release is not a deploy
9090 # of g1t.sh.
9191 environment:
+4−4
8484 jobs:
8585 check:
8686 name: Check
87− runs-on: ubuntu-latest
87+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
8888 timeout-minutes: 20
8989 steps:
9090 - uses: actions/checkout@v5
109109 # Runs beside check: nothing deploys until both have succeeded.
110110 plan:
111111 name: Plan
112− runs-on: ubuntu-latest
112+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
113113 # Production's secrets, without a deployment: planning deploys nothing.
114114 environment:
115115 name: production
271271 name: Migrations
272272 needs: [check, plan]
273273 if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }}
274− runs-on: ubuntu-latest
274+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
275275 environment:
276276 name: production
277277 url: https://g1t.sh
453453 name: Smoke
454454 needs: [check, plan, core, edge, front]
455455 if: ${{ !failure() && !cancelled() && inputs.dry_run != true && (needs.plan.outputs.has_core == 'true' || needs.plan.outputs.has_edge == 'true' || needs.plan.outputs.has_front == 'true') }}
456− runs-on: ubuntu-latest
456+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
457457 timeout-minutes: 5
458458 steps:
459459 - uses: actions/checkout@v5
+2−2
4747 jobs:
4848 version:
4949 name: The tag names this version
50− runs-on: ubuntu-latest
50+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
5151 outputs:
5252 version: ${{ steps.read.outputs.version }}
5353 steps:
292292 && contains(fromJSON('["success","skipped"]'), needs.windows.result)
293293 && contains(fromJSON('["success","skipped"]'), needs.windows_cross.result)
294294 && contains(fromJSON('["success","skipped"]'), needs.macos.result)
295− runs-on: ubuntu-latest
295+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
296296 # Production's secrets, without a deployment: a release is not a deploy of
297297 # g1t.sh, and would otherwise show on its card as one in progress.
298298 environment:
+1−1
5050 jobs:
5151 publish:
5252 name: Publish flagon-io/g1t-plugins
53− runs-on: ubuntu-latest
53+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
5454 timeout-minutes: 10
5555 steps:
5656 - uses: actions/checkout@v5
+1−1
231231 # The binaries go out even when an image job failed: the manifest then
232232 # names no sandbox images, and computers keep the sandbox they have.
233233 if: ${{ always() && needs.binaries.result == 'success' }}
234− runs-on: ubuntu-latest
234+ runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough
235235 environment:
236236 name: production
237237 deployment: false
+10−4
489489 // restores, and a save step of the same entry after the prune saves
490490 // when it runs.
491491 if saves(&job.steps[at]) {
492− assert_eq!(
493− condition,
494− format!("${{{{ steps.{cache}.outputs.cache-hit != 'true' }}}}"),
495− "{file} {id}"
492+ // A job a pull request may pass without running (ci-affected.mjs
493+ // --job) asks that too: skipped, the cache step says no hit.
494+ let hit = format!("steps.{cache}.outputs.cache-hit != 'true' }}}}");
495+ assert!(
496+ [
497+ format!("${{{{ {hit}"),
498+ format!("${{{{ steps.affected.outputs.run == 'true' && {hit}"),
499+ ]
500+ .contains(&condition.to_string()),
501+ "{file} {id}: {condition}"
496502 );
497503 } else {
498504 let key = &job.steps[at].raw["with"]["key"];
+67−0
55 // and a part compiled and tested all of its own whatever changed.
66 //
77 // node scripts/deploy/ci-affected.mjs -p <crate>...
8+// node scripts/deploy/ci-affected.mjs --job desktop|build
9+//
10+// With --job it says whether a pull request needs one of CI's other jobs
11+// (JOBS), as the output `run` (true or false), on the same terms: not a
12+// pull request, or anything failing, runs it.
813 //
914 // It takes the part's `-p` arguments and writes to the step's outputs
1015 // (GITHUB_OUTPUT):
8186 ["crates/scan/fixtures/", ["g1t-security"]],
8287 ];
8388
89+/**
90+ * The desktop job's files: the app, outside the workspace with a lockfile
91+ * of its own and no path dependency on it; the release scripts whose tests
92+ * the job runs, and the CLI's manifest cli-release.mjs reads its version
93+ * from; what installs Tauri's packages; and the toolchain and this
94+ * workflow's own files.
95+ */
96+export const DESKTOP = [
97+ "apps/desktop/",
98+ "scripts/desktop-release.mjs",
99+ "scripts/desktop-release.test.mjs",
100+ "scripts/mobile-release.mjs",
101+ "scripts/mobile-release.test.mjs",
102+ "scripts/cli-release.mjs",
103+ "scripts/cli-release.test.mjs",
104+ "crates/g1t/Cargo.toml",
105+ "scripts/apt-install.sh",
106+ "rust-toolchain",
107+ "rust-toolchain.toml",
108+ ".cargo/",
109+ ".g1t/workflows/ci.yml",
110+ "scripts/deploy/ci-affected.mjs",
111+ "scripts/deploy/ci-cache.mjs",
112+];
113+
114+/**
115+ * CI's other jobs a pull request may pass without running, and whether a
116+ * changed file needs each: `desktop` its own files (DESKTOP), `build` (the
117+ * site, sudo and the docs) any file but Rust source, which none of them
118+ * reads. TypeScript runs on every pull request: its tests and the
119+ * conformance suite read Rust source and manifests throughout.
120+ */
121+export const JOBS = {
122+ desktop: (file) => DESKTOP.some((entry) => under(file, entry)),
123+ build: (file) => !file.endsWith(".rs"),
124+};
125+
126+/** Whether a change to `files` needs `job` (a key of JOBS) to run. */
127+export function needs(job, files) {
128+ return files.some(JOBS[job]);
129+}
130+
84131 /** Whether `path` is `entry`, or under it when `entry` is a folder. */
85132 function under(path, entry) {
86133 return entry.endsWith("/") ? path.startsWith(entry) : path === entry;
155202 return { files };
156203 }
157204
205+/** `--job <name>`: writes `run`, whether this run needs the job. */
206+function mainJob(job) {
207+ if (!JOBS[job]) throw new Error(`no job ${job}: one of ${Object.keys(JOBS).join(", ")}`);
208+ let run = true;
209+ try {
210+ const { files, why } = changedFiles();
211+ if (files) {
212+ run = needs(job, files);
213+ console.log(`${files.length} files changed; ${run ? `the ${job} job's among them` : `none of them the ${job} job's, so it passes without running`}`);
214+ } else {
215+ console.log(why.replace("tests every crate", `runs the ${job} job`));
216+ }
217+ } catch (error) {
218+ console.log(`::warning title=Affected files::${error.message.split("\n")[0]}; the ${job} job runs`);
219+ run = true;
220+ }
221+ if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `run=${run}\n`);
222+}
223+
158224 function main(args) {
225+ if (args[0] === "--job") return mainJob(args[1]);
159226 const part = partCrates(args);
160227 let test = part;
161228 try {
+32−1
33 import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
44 import { dirname, join, relative, resolve, sep } from "node:path";
55 import { fileURLToPath } from "node:url";
6−import { EVERYTHING, READS, affected, partCrates, workspace } from "./ci-affected.mjs";
6+import { DESKTOP, EVERYTHING, READS, affected, needs, partCrates, workspace } from "./ci-affected.mjs";
77
88 const ROOT = fileURLToPath(new URL("../..", import.meta.url));
99
5050 assert.deepEqual(affected(CRATES, []), []);
5151 });
5252
53+test("the desktop job runs for its own files and the build's, and for nothing else", () => {
54+ for (const file of [
55+ "apps/desktop/src-tauri/src/main.rs",
56+ "apps/desktop/package.json",
57+ "scripts/cli-release.test.mjs",
58+ "scripts/apt-install.sh",
59+ ".g1t/workflows/ci.yml",
60+ ]) {
61+ assert.equal(needs("desktop", ["crates/kit/src/lib.rs", file]), true, file);
62+ }
63+ assert.equal(needs("desktop", ["crates/kit/src/lib.rs", "apps/web/app/root.tsx", "Cargo.lock", "apps/desktopper/x.ts"]), false);
64+});
65+
66+test("the site, sudo and the docs build for any change but Rust source alone", () => {
67+ assert.equal(needs("build", ["crates/kit/src/lib.rs", "services/work/src/items.rs"]), false);
68+ assert.equal(needs("build", ["crates/kit/src/lib.rs", "crates/kit/Cargo.toml"]), true);
69+ assert.equal(needs("build", ["apps/docs/src/content/docs/index.md"]), true);
70+ assert.equal(needs("build", []), false);
71+});
72+
73+test("every file the desktop job names is there", () => {
74+ // The toolchain's files, like EVERYTHING's, are there only when pinned.
75+ for (const entry of DESKTOP.filter((e) => !EVERYTHING.includes(e))) assert.ok(existsSync(join(ROOT, entry)), entry);
76+ // Its tests run each release script's test file.
77+ const ci = readFileSync(join(ROOT, ".g1t/workflows/ci.yml"), "utf8");
78+ const tests = ci.match(/node --test scripts\/desktop-release\.test\.mjs.*/)[0];
79+ for (const test of tests.match(/scripts\/[\w-]+\.test\.mjs/g)) assert.ok(DESKTOP.includes(test), test);
80+ // Outside the workspace, with no path dependency on it.
81+ assert.doesNotMatch(readFileSync(join(ROOT, "apps/desktop/src-tauri/Cargo.toml"), "utf8"), /\bpath\s*=/);
82+});
83+
5384 test("a part's crates are read from its -p arguments", () => {
5485 assert.deepEqual(partCrates(["-p", "g1t-kit", "-p", "g1t"]), ["g1t-kit", "g1t"]);
5586 assert.throws(() => partCrates(["-p", "g1t-kit", "g1t"]));

Comments

Sign in to comment.