Skip to content

Commit 939fb50

Browse files
committed
build(ci): optimize AppMap test binaries cache and eliminate save churn
Prevent the GitHub Actions AppMap test binaries cache from growing unboundedly, and completely eliminate redundant cache save churn (zipping and network uploads) on steady-state runs. - Transition from `actions/cache@v5` to explicit, decoupled `actions/cache/restore@v5` and `actions/cache/save@v5` steps. - Add `id: restore-cache` to track the matched key retrieved from GitHub. - Create `.github/workflows/clean-cache.js` to parse version numbers of surviving binaries, prune old releases, delete test stub files, and export a SHA-256 hash of the final stable binaries set to `$GITHUB_OUTPUT`. - Configure the `Save AppMap test binaries cache` step to compare the new hash-based key against the matched/restored key. If they match, the step is completely skipped, saving network bandwidth and runner CPU. - Configure steps with `continue-on-error: true` to defensively guard the build from any unexpected script/cache failures. - Add descriptive comments in the workflow explaining the decoupled restore/save setup, the pruning logic, and the matched-key conditional optimization.
1 parent 480de29 commit 939fb50

2 files changed

Lines changed: 155 additions & 3 deletions

File tree

.github/workflows/build.yml

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,15 @@ jobs:
4545
with:
4646
cache-disabled: true
4747

48-
- name: Cache AppMap test binaries
49-
uses: actions/cache@v5
48+
# Restore cached AppMap/Scanner test binaries from previous runs using restore-keys.
49+
# We explicitly use actions/cache/restore (instead of actions/cache) to decouple
50+
# restoring and saving, allowing us to only save a new cache when new versions are downloaded.
51+
- name: Restore AppMap test binaries cache
52+
id: restore-cache
53+
uses: actions/cache/restore@v5
5054
with:
5155
path: ~/.cache/appmap-test
52-
key: appmap-test-binaries-${{ runner.os }}-${{ github.run_id }}
56+
key: appmap-test-binaries-${{ runner.os }}-initial
5357
restore-keys: appmap-test-binaries-${{ runner.os }}-
5458

5559
# https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limits-for-requests-from-github-actions
@@ -73,6 +77,26 @@ jobs:
7377
build/reports/
7478
**/build/reports/
7579
80+
# Clean up the cache directory before saving to keep it lean.
81+
# The script sorts downloaded binaries by SemVer, deletes old versions,
82+
# prunes test stubs, and outputs a SHA-256 hash of the surviving stable files.
83+
- name: Clean AppMap test binaries cache
84+
id: clean-cache
85+
if: always()
86+
continue-on-error: true
87+
run: node .github/workflows/clean-cache.js
88+
89+
# Save the cache with the unique hash of the preserved binaries.
90+
# To avoid CPU and network upload churn on identical states (since GitHub caches are immutable),
91+
# we explicitly skip saving if the newly generated key matches the key we restored from.
92+
- name: Save AppMap test binaries cache
93+
if: always() && steps.clean-cache.outputs.cache_hash != '' && steps.clean-cache.outputs.cache_hash != 'empty' && steps.restore-cache.outputs.cache-matched-key != format('appmap-test-binaries-{0}-{1}', runner.os, steps.clean-cache.outputs.cache_hash)
94+
continue-on-error: true
95+
uses: actions/cache/save@v5
96+
with:
97+
path: ~/.cache/appmap-test
98+
key: appmap-test-binaries-${{ runner.os }}-${{ steps.clean-cache.outputs.cache_hash }}
99+
76100
- name: Save AppMaps
77101
uses: actions/cache/save@v5
78102
if: runner.os == 'Linux' && matrix.platform_version == '251'

.github/workflows/clean-cache.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const os = require('os');
4+
const crypto = require('crypto');
5+
6+
function parseSemVer(versionStr) {
7+
if (versionStr.startsWith('v')) {
8+
versionStr = versionStr.substring(1);
9+
}
10+
const [main, prerelease] = versionStr.split('-');
11+
const parts = main.split('.').map(Number);
12+
return {
13+
parts,
14+
prerelease: prerelease || null
15+
};
16+
}
17+
18+
function compareSemVer(v1, v2) {
19+
const s1 = parseSemVer(v1);
20+
const s2 = parseSemVer(v2);
21+
22+
for (let i = 0; i < 3; i++) {
23+
const p1 = s1.parts[i] || 0;
24+
const p2 = s2.parts[i] || 0;
25+
if (p1 !== p2) {
26+
return p1 - p2;
27+
}
28+
}
29+
30+
if (s1.prerelease && !s2.prerelease) return -1;
31+
if (!s1.prerelease && s2.prerelease) return 1;
32+
33+
if (s1.prerelease && s2.prerelease) {
34+
return s1.prerelease.localeCompare(s2.prerelease, undefined, { numeric: true, sensitivity: 'base' });
35+
}
36+
37+
return 0;
38+
}
39+
40+
const cacheDir = process.argv[2] || path.join(os.homedir(), '.cache', 'appmap-test');
41+
42+
console.log(`Target cache directory: ${cacheDir}`);
43+
44+
if (!fs.existsSync(cacheDir)) {
45+
console.log(`Cache directory does not exist. Nothing to clean.`);
46+
if (process.env.GITHUB_OUTPUT) {
47+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `cache_hash=empty\n`);
48+
}
49+
process.exit(0);
50+
}
51+
52+
try {
53+
const files = fs.readdirSync(cacheDir);
54+
// Matches names like appmap-linux-x64-1.2.3 and appmap-win-x64-1.2.3.exe
55+
// The lazy quantifier "+?" ensures we do not greedily swallow .exe if present
56+
const nameRegex = /^(appmap|scanner)-([a-z0-9]+)-([a-z0-9]+)-([0-9a-zA-Z_.-]+?)(?:\.exe)?$/i;
57+
58+
const groups = {};
59+
60+
for (const file of files) {
61+
const match = file.match(nameRegex);
62+
if (!match) {
63+
console.log(`Skipping non-matching file: ${file}`);
64+
continue;
65+
}
66+
67+
const tool = match[1].toLowerCase();
68+
const platform = match[2].toLowerCase();
69+
const arch = match[3].toLowerCase();
70+
const version = match[4];
71+
72+
const key = `${tool}-${platform}-${arch}`;
73+
if (!groups[key]) {
74+
groups[key] = [];
75+
}
76+
groups[key].push({
77+
filename: file,
78+
version: version
79+
});
80+
}
81+
82+
for (const key of Object.keys(groups)) {
83+
const groupFiles = groups[key];
84+
if (groupFiles.length <= 1) {
85+
console.log(`Group ${key} has ${groupFiles.length} file(s). No cleanup needed.`);
86+
continue;
87+
}
88+
89+
// Sort descending (highest version first)
90+
groupFiles.sort((a, b) => compareSemVer(b.version, a.version));
91+
92+
const keep = groupFiles[0];
93+
console.log(`Group ${key}: keeping ${keep.filename} (${keep.version})`);
94+
95+
for (let i = 1; i < groupFiles.length; i++) {
96+
const discard = groupFiles[i];
97+
const fullPath = path.join(cacheDir, discard.filename);
98+
console.log(` Deleting obsolete version: ${discard.filename} (${discard.version})`);
99+
try {
100+
fs.unlinkSync(fullPath);
101+
} catch (err) {
102+
console.error(` Failed to delete ${discard.filename}:`, err);
103+
}
104+
}
105+
}
106+
107+
// Compute a hash of the remaining matching files to uniquely identify this cache state.
108+
// Since we delete obsolete files, stub files, etc., the remaining files will be exactly
109+
// the stable versions we want to cache.
110+
const remainingFiles = fs.readdirSync(cacheDir)
111+
.filter(f => nameRegex.test(f))
112+
.sort();
113+
114+
const hashInput = remainingFiles.join(',');
115+
const hash = crypto.createHash('sha256').update(hashInput).digest('hex');
116+
117+
console.log(`Remaining cache files: ${remainingFiles.join(', ') || '(none)'}`);
118+
console.log(`Generated cache hash: ${hash}`);
119+
120+
if (process.env.GITHUB_OUTPUT) {
121+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `cache_hash=${hash}\n`);
122+
}
123+
124+
console.log('Cache cleanup complete.');
125+
} catch (err) {
126+
console.error('Error during cache cleanup:', err);
127+
process.exit(1);
128+
}

0 commit comments

Comments
 (0)