Skip to content

Commit 3a1417f

Browse files
authored
ci: Update clear cache action to be smarter (#13405)
Previously, we had a CI action to manually clear all caches. This PR adjusts this so this action can be used in a more granular way: * By default, the action will now delete caches of any PR runs that are successful, as well as any caches of release branches. * You can configure to also delete caches on the develop branch, and/or to also delete non-successful PR branches. Additionally, this action will run every midnight, to automatically clear completed/outdated stuff. The goal is to keep develop caches as long as possible, and clear out other caches, unless they failed (which indicates you may want to re-run some of the tests) and unless they are currently running (to not break ongoing tests). Ideally, we do not need to manually run this, but can rely on automated cleanup over night.
1 parent 60271f5 commit 3a1417f

File tree

7 files changed

+280
-3
lines changed

7 files changed

+280
-3
lines changed

.github/workflows/clear-cache.yml

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,43 @@
11
name: "Action: Clear all GHA caches"
22
on:
33
workflow_dispatch:
4+
inputs:
5+
clear_pending_prs:
6+
description: Delete caches of pending PR workflows
7+
type: boolean
8+
default: false
9+
clear_develop:
10+
description: Delete caches on develop branch
11+
type: boolean
12+
default: false
13+
clear_branches:
14+
description: Delete caches on non-develop branches
15+
type: boolean
16+
default: true
17+
schedule:
18+
# Run every day at midnight
19+
- cron: '0 0 * * *'
420

521
jobs:
622
clear-caches:
723
name: Delete all caches
824
runs-on: ubuntu-20.04
925
steps:
10-
- name: Clear caches
11-
uses: easimon/wipe-cache@v2
26+
- uses: actions/checkout@v4
27+
28+
- name: Set up Node
29+
uses: actions/setup-node@v4
30+
with:
31+
node-version-file: 'package.json'
32+
33+
# TODO: Use cached version if possible (but never store cache)
34+
- name: Install dependencies
35+
run: yarn install --frozen-lockfile
36+
37+
- name: Delete GHA caches
38+
uses: ./dev-packages/clear-cache-gh-action
39+
with:
40+
clear_pending_prs: ${{ inputs.clear_pending_prs }}
41+
clear_develop: ${{ inputs.clear_develop }}
42+
clear_branches: ${{ inputs.clear_branches }}
43+
github_token: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/external-contributors.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ jobs:
2525
uses: actions/setup-node@v4
2626
with:
2727
node-version-file: 'package.json'
28-
cache: 'yarn'
2928

3029
- name: Install dependencies
3130
run: yarn install --frozen-lockfile
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
extends: ['../../.eslintrc.js'],
3+
parserOptions: {
4+
sourceType: 'module',
5+
ecmaVersion: 'latest',
6+
},
7+
8+
overrides: [
9+
{
10+
files: ['*.mjs'],
11+
extends: ['@sentry-internal/sdk/src/base'],
12+
},
13+
],
14+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: 'clear-cache-gh-action'
2+
description: 'Clear caches of the GitHub repository.'
3+
inputs:
4+
github_token:
5+
required: true
6+
description: 'a github access token'
7+
clear_develop:
8+
required: false
9+
default: ""
10+
description: "If set, also clear caches from develop branch."
11+
clear_branches:
12+
required: false
13+
default: ""
14+
description: "If set, also clear caches from non-develop branches."
15+
clear_pending_prs:
16+
required: false
17+
default: ""
18+
description: "If set, also clear caches from pending PR workflow runs."
19+
workflow_name:
20+
required: false
21+
default: "CI: Build & Test"
22+
description: The workflow to clear caches for.
23+
runs:
24+
using: 'node20'
25+
main: 'index.mjs'
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import * as core from '@actions/core';
2+
3+
import { context, getOctokit } from '@actions/github';
4+
5+
async function run() {
6+
const { getInput } = core;
7+
8+
const { repo, owner } = context.repo;
9+
10+
const githubToken = getInput('github_token');
11+
const clearDevelop = inputToBoolean(getInput('clear_develop', { type: 'boolean' }));
12+
const clearBranches = inputToBoolean(getInput('clear_branches', { type: 'boolean', default: true }));
13+
const clearPending = inputToBoolean(getInput('clear_pending_prs', { type: 'boolean' }));
14+
const workflowName = getInput('workflow_name');
15+
16+
const octokit = getOctokit(githubToken);
17+
18+
await clearGithubCaches(octokit, {
19+
repo,
20+
owner,
21+
clearDevelop,
22+
clearPending,
23+
clearBranches,
24+
workflowName,
25+
});
26+
}
27+
28+
/**
29+
* Clear caches.
30+
*
31+
* @param {ReturnType<import("@actions/github").getOctokit> } octokit
32+
* @param {{repo: string, owner: string, clearDevelop: boolean, clearPending: boolean, clearBranches: boolean, workflowName: string}} options
33+
*/
34+
async function clearGithubCaches(octokit, { repo, owner, clearDevelop, clearPending, clearBranches, workflowName }) {
35+
let deletedCaches = 0;
36+
let remainingCaches = 0;
37+
38+
let deletedSize = 0;
39+
let remainingSize = 0;
40+
41+
/** @type {Map<number, ReturnType<typeof octokit.rest.pulls.get>>} */
42+
const cachedPrs = new Map();
43+
/** @type {Map<string, ReturnType<typeof octokit.rest.actions.listWorkflowRunsForRepo>>} */
44+
const cachedWorkflows = new Map();
45+
46+
/**
47+
* Clear caches.
48+
*
49+
* @param {{ref: string}} options
50+
*/
51+
const shouldClearCache = async ({ ref }) => {
52+
// Do not clear develop caches if clearDevelop is false.
53+
if (!clearDevelop && ref === 'refs/heads/develop') {
54+
core.info('> Keeping cache because it is on develop.');
55+
return false;
56+
}
57+
58+
// There are two fundamental paths here:
59+
// If the cache belongs to a PR, we need to check if the PR has any pending workflows.
60+
// Else, we assume the cache belongs to a branch, where we do not check for pending workflows
61+
const pullNumber = /^refs\/pull\/(\d+)\/merge$/.exec(ref)?.[1];
62+
const isPr = !!pullNumber;
63+
64+
// Case 1: This is a PR, and we do not want to clear pending PRs
65+
// In this case, we need to fetch all PRs and workflow runs to check them
66+
if (isPr && !clearPending) {
67+
const pr =
68+
cachedPrs.get(pullNumber) ||
69+
(await octokit.rest.pulls.get({
70+
owner,
71+
repo,
72+
pull_number: pullNumber,
73+
}));
74+
cachedPrs.set(pullNumber, pr);
75+
76+
const prBranch = pr.data.head.ref;
77+
78+
// Check if PR has any pending workflows
79+
const workflowRuns =
80+
cachedWorkflows.get(prBranch) ||
81+
(await octokit.rest.actions.listWorkflowRunsForRepo({
82+
repo,
83+
owner,
84+
branch: prBranch,
85+
}));
86+
cachedWorkflows.set(prBranch, workflowRuns);
87+
88+
// We only care about the relevant workflow
89+
const relevantWorkflowRuns = workflowRuns.data.workflow_runs.filter(workflow => workflow.name === workflowName);
90+
91+
const latestWorkflowRun = relevantWorkflowRuns[0];
92+
93+
core.info(`> Latest relevant workflow run: ${latestWorkflowRun.html_url}`);
94+
95+
// No relevant workflow? Clear caches!
96+
if (!latestWorkflowRun) {
97+
core.info('> Clearing cache because no relevant workflow was found.');
98+
return true;
99+
}
100+
101+
// If the latest run was not successful, keep caches
102+
// as either the run may be in progress,
103+
// or failed - in which case we may want to re-run the workflow
104+
if (latestWorkflowRun.conclusion !== 'success') {
105+
core.info(`> Keeping cache because latest workflow is ${latestWorkflowRun.conclusion}.`);
106+
return false;
107+
}
108+
109+
core.info(`> Clearing cache because latest workflow run is ${latestWorkflowRun.conclusion}.`);
110+
return true;
111+
}
112+
113+
// Case 2: This is a PR, but we do want to clear pending PRs
114+
// In this case, this cache should always be cleared
115+
if (isPr) {
116+
core.info('> Clearing cache of every PR workflow run.');
117+
return true;
118+
}
119+
120+
// Case 3: This is not a PR, and we want to clean branches
121+
if (clearBranches) {
122+
core.info('> Clearing cache because it is not a PR.');
123+
return true;
124+
}
125+
126+
// Case 4: This is not a PR, and we do not want to clean branches
127+
core.info('> Keeping cache for non-PR workflow run.');
128+
return false;
129+
};
130+
131+
for await (const response of octokit.paginate.iterator(octokit.rest.actions.getActionsCacheList, {
132+
owner,
133+
repo,
134+
})) {
135+
if (!response.data.length) {
136+
break;
137+
}
138+
139+
for (const { id, ref, size_in_bytes } of response.data) {
140+
core.info(`Checking cache ${id} for ${ref}...`);
141+
142+
const shouldDelete = await shouldClearCache({ ref });
143+
144+
if (shouldDelete) {
145+
core.info(`> Clearing cache ${id}...`);
146+
147+
deletedCaches++;
148+
deletedSize += size_in_bytes;
149+
150+
await octokit.rest.actions.deleteActionsCacheById({
151+
owner,
152+
repo,
153+
cache_id: id,
154+
});
155+
} else {
156+
remainingCaches++;
157+
remainingSize += size_in_bytes;
158+
}
159+
}
160+
}
161+
162+
const format = new Intl.NumberFormat('en-US', {
163+
style: 'decimal',
164+
});
165+
166+
core.info('Summary:');
167+
core.info(`Deleted ${deletedCaches} caches, freeing up ~${format.format(deletedSize / 1000 / 1000)} mb.`);
168+
core.info(`Remaining ${remainingCaches} caches, using ~${format.format(remainingSize / 1000 / 1000)} mb.`);
169+
}
170+
171+
run();
172+
173+
function inputToBoolean(input) {
174+
if (typeof input === 'boolean') {
175+
return input;
176+
}
177+
178+
if (typeof input === 'string') {
179+
return input === 'true';
180+
}
181+
182+
return false;
183+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@sentry-internal/clear-cache-gh-action",
3+
"description": "An internal Github Action to clear GitHub caches.",
4+
"version": "8.26.0",
5+
"license": "MIT",
6+
"engines": {
7+
"node": ">=18"
8+
},
9+
"private": true,
10+
"main": "index.mjs",
11+
"type": "module",
12+
"scripts": {
13+
"lint": "eslint . --format stylish",
14+
"fix": "eslint . --format stylish --fix"
15+
},
16+
"dependencies": {
17+
"@actions/core": "1.10.1",
18+
"@actions/github": "^5.0.0"
19+
},
20+
"volta": {
21+
"extends": "../../package.json"
22+
}
23+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"dev-packages/overhead-metrics",
9292
"dev-packages/test-utils",
9393
"dev-packages/size-limit-gh-action",
94+
"dev-packages/clear-cache-gh-action",
9495
"dev-packages/external-contributor-gh-action",
9596
"dev-packages/rollup-utils"
9697
],

0 commit comments

Comments
 (0)