Skip to content

Commit 5bf9a1d

Browse files
committed
Bug Fixes
1 parent 63b223f commit 5bf9a1d

File tree

4 files changed

+104
-74
lines changed

4 files changed

+104
-74
lines changed

README.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ A GitHub Action to delete workflow runs in a repository. This Action uses JavaSc
3434
- Improved `delete_workflow_pattern` matching (supports name or filename).
3535

3636
Notes:
37-
- Inputs names reflect the action's expected input keys. Do not change names in your workflow unless you have updated the Action code accordingly.
37+
- Input names reflect the action's expected input keys. Do not change names in your workflow unless you have updated the Action code accordingly.
3838
- If an input has a default value, it is optional in your workflow inputs.
3939
- For delete_workflow_pattern you can provide multiple filters separated by the pipe character `|` (interpreted as logical OR). For more complex matching, combine with other inputs.
4040

@@ -46,12 +46,18 @@ The token used must allow the Action to list and delete workflow runs. Recommend
4646

4747
Using `${{ github.token }}` in workflows is recommended for the current repository. For cross-repository operations or if you need broader scope, use a Personal Access Token (PAT) with `repo` scope and appropriate permissions.
4848

49-
## Setup (development / publishing)
49+
## Setup
5050

51-
1. Ensure `package.json` includes dependencies and a build script using `@vercel/ncc`.
52-
2. Run `npm install` and `npm run build` to generate `dist/index.js`.
53-
3. Commit the `dist/` folder (compiled bundle) to the repository. Do NOT commit `node_modules/` — use `.gitignore` to exclude it.
54-
4. Tag and release versions as needed (the action can be referenced by `@v2`, `@v2.1.0`, or a full SHA).
51+
To use this Action in your workflows:
52+
53+
- Reference a released tag, the major tag, or a specific commit SHA, for example:
54+
- uses: Mattraks/delete-workflow-runs@v2
55+
- uses: Mattraks/[email protected]
56+
- uses: Mattraks/delete-workflow-runs@\<full-sha>
57+
- Ensure the workflow grants the Action the permissions it needs (actions: write, contents: read).
58+
- Provide a token via the `token` input. For operations on repositories other than the workflow repository or for private repositories, use a PAT with `repo` scope (store it in GitHub Secrets).
59+
- Configure inputs (retain_days, keep_minimum_runs, delete_workflow_pattern, etc.) per your policy. See the Examples section below for typical workflows (scheduled, manual, matrix).
60+
- For GitHub Enterprise Server, set `baseUrl` to your API base (e.g. `https://github.mycompany.com/api/v3`).
5561

5662
## Examples
5763

dist/index.js

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

index.js

Lines changed: 87 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const parseBoolean = (input, falsyValues = ["0", "no", "n", "false"]) => {
1616
return !falsyValues.includes(normalized);
1717
};
1818
/**
19-
* Split a comma-separated pattern into trimmed items.
19+
* Split a comma- or pipe-separated pattern into trimmed items.
2020
* If pattern is empty/undefined returns an empty array.
2121
* @param {string|undefined} pattern
2222
* @returns {string[]}
@@ -37,7 +37,7 @@ async function deleteRuns(runs, context, dryRun, octokit, owner, repo) {
3737
core.debug(`[${context}] No runs to delete.`);
3838
return;
3939
}
40-
const tasks = runs.map((run) => async () => {
40+
const tasks = runs.map(run => async () => {
4141
if (dryRun) {
4242
core.info(`[dry-run] 🚀 Simulate deletion: Run ${run.id} (${context})`);
4343
return { status: "skipped", runId: run.id };
@@ -51,7 +51,7 @@ async function deleteRuns(runs, context, dryRun, octokit, owner, repo) {
5151
return { status: "failed", runId: run.id, error: err };
5252
}
5353
});
54-
const results = await Promise.allSettled(tasks.map((t) => t()));
54+
const results = await Promise.allSettled(tasks.map(t => t()));
5555
const summary = results.reduce(
5656
(acc, res) => {
5757
const status = res.status === "fulfilled" ? res.value?.status : null;
@@ -75,8 +75,7 @@ async function deleteRuns(runs, context, dryRun, octokit, owner, repo) {
7575
* @returns {boolean}
7676
*/
7777
function shouldDeleteRun(run, options) {
78-
const { checkPullRequestExist, checkBranchExistence, branchNames, allowedConclusions, retainDays } = options;
79-
// Only completed runs are considered.
78+
const { checkPullRequestExist, checkBranchExistence, branchNames, allowedConclusions, retainDays = 0, skipAgeCheck = false } = options;
8079
if (run.status !== "completed") {
8180
core.debug(`💬 Skip: Run ${run.id} status=${run.status}`);
8281
return false;
@@ -93,62 +92,87 @@ function shouldDeleteRun(run, options) {
9392
return false;
9493
}
9594
// Conclusion filter (if provided). If allowedConclusions is empty, that means "ALL".
96-
if (allowedConclusions.length > 0 && !allowedConclusions.includes(run.conclusion)) {
97-
core.debug(`💬 Skip: Run ${run.id} conclusion="${run.conclusion}" not allowed`);
98-
return false;
95+
if (allowedConclusions.length > 0) {
96+
const runConclusion = String(run.conclusion ?? "").toLowerCase();
97+
if (!allowedConclusions.includes(runConclusion)) {
98+
core.debug(`💬 Skip: Run ${run.id} conclusion="${run.conclusion}" not allowed`);
99+
return false;
100+
}
99101
}
100-
// Age filter
101-
const ageDays = (Date.now() - new Date(run.created_at).getTime()) / 86400000;
102-
if (ageDays < retainDays) {
103-
core.debug(`💬 Skip: Run ${run.id} is ${ageDays.toFixed(1)} days old (< ${retainDays} days)`);
104-
return false;
102+
// Age filter only when requested
103+
if (!skipAgeCheck && retainDays > 0) {
104+
if (!run.created_at) {
105+
core.debug(`💬 Skip age check: Run ${run.id} has no created_at`);
106+
return false;
107+
}
108+
const ageDays = (Date.now() - new Date(run.created_at).getTime()) / 86400000;
109+
if (ageDays < retainDays) {
110+
core.debug(`💬 Skip: Run ${run.id} is ${ageDays.toFixed(1)} days old (< ${retainDays} days)`);
111+
return false;
112+
}
105113
}
106-
// All checks passed → delete
107114
return true;
108115
}
109116
/**
110117
* Group runs by date and filter runs to retain per day
111118
* @param {Array} runs
112-
* @param {number} keepMinimumRunsPerDay
119+
* @param {number} keepMinimumRuns
120+
* @param {number} retainDays
113121
* @returns {Object} { runsToDelete: Array, runsToRetain: Array }
114122
*/
115-
function filterRunsByDailyRetention(runs, keepMinimumRunsPerDay) {
116-
if (keepMinimumRunsPerDay <= 0) {
117-
return { runsToDelete: runs, runsToRetain: [] };
123+
function filterRunsByDailyRetention(runs, keepMinimumRuns, retainDays) {
124+
if (keepMinimumRuns <= 0 || retainDays <= 0) {
125+
return {
126+
runsToDelete: runs,
127+
runsToRetain: []
128+
};
118129
}
119-
// Group runs by date (YYYY-MM-DD)
130+
const cutoffDate = new Date();
131+
cutoffDate.setDate(cutoffDate.getDate() - retainDays);
132+
const cutoffTime = cutoffDate.getTime();
120133
const runsByDate = {};
134+
const expiredRuns = []; // older than retainDays → delete
121135
runs.forEach(run => {
122-
const date = new Date(run.created_at).toISOString().split('T')[0]; // Get YYYY-MM-DD
123-
if (!runsByDate[date]) {
124-
runsByDate[date] = [];
136+
if (!run?.created_at) {
137+
// If no created_at treat as expired to be safe
138+
expiredRuns.push(run);
139+
return;
140+
}
141+
const runTime = new Date(run.created_at).getTime();
142+
if (isNaN(runTime) || runTime < cutoffTime) {
143+
expiredRuns.push(run);
144+
return;
125145
}
126-
runsByDate[date].push(run);
146+
// Normalize date key via ISO to avoid locale variations
147+
const dateKey = new Date(run.created_at).toISOString().split("T")[0]; // YYYY-MM-DD
148+
if (!runsByDate[dateKey])
149+
runsByDate[dateKey] = [];
150+
runsByDate[dateKey].push(run);
127151
});
128-
const runsToDelete = [];
129152
const runsToRetain = [];
130-
// For each date, keep the latest keepMinimumRunsPerDay runs
153+
const runsToDelete = [...expiredRuns];
131154
Object.values(runsByDate).forEach(dateRuns => {
132-
// Sort by creation time (newest first)
133-
dateRuns.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
134-
// Keep the latest N runs for this date
135-
const retainedRuns = dateRuns.slice(0, keepMinimumRunsPerDay);
136-
const deletedRuns = dateRuns.slice(keepMinimumRunsPerDay);
137-
runsToRetain.push(...retainedRuns);
138-
runsToDelete.push(...deletedRuns);
155+
dateRuns.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); // newest first
156+
const retain = dateRuns.slice(0, keepMinimumRuns);
157+
const del = dateRuns.slice(keepMinimumRuns);
158+
runsToRetain.push(...retain);
159+
runsToDelete.push(...del);
139160
});
140161
return { runsToDelete, runsToRetain };
141162
}
142163
async function run() {
143164
try {
144165
// ---------------------- 1. Parse Input Parameters ----------------------
145166
const token = core.getInput("token");
167+
if (!token)
168+
throw new Error("Missing required input: token");
146169
const baseUrl = core.getInput("baseUrl");
147170
const repositoryInput = core.getInput("repository");
171+
if (!repositoryInput)
172+
throw new Error('Missing required input: repository (expected "owner/repo")');
148173
const [repoOwner, repoName] = repositoryInput.split("/");
149-
if (!repoOwner || !repoName) {
174+
if (!repoOwner || !repoName)
150175
throw new Error(`Invalid repository: "${repositoryInput}". Use "owner/repo".`);
151-
}
152176
const retainDays = Number(core.getInput("retain_days") || "30");
153177
const keepMinimumRuns = Number(core.getInput("keep_minimum_runs") || "6");
154178
const useDailyRetention = parseBoolean(core.getInput("use_daily_retention"));
@@ -168,9 +192,7 @@ async function run() {
168192
core.warning(`Rate limit: ${options.method} ${options.url} — wait ${retryAfter}s`);
169193
return retryAfter < 5;
170194
},
171-
onSecondaryRateLimit: (retryAfter, options) => {
172-
core.warning(`Secondary rate limit: ${options.method} ${options.url}`);
173-
},
195+
onSecondaryRateLimit: () => core.warning("Secondary rate limit hit"),
174196
},
175197
});
176198
// ---------------------- 3. Fetch Workflows ----------------------
@@ -179,7 +201,7 @@ async function run() {
179201
repo: repoName,
180202
per_page: 100,
181203
});
182-
const workflowIds = workflows.map((w) => w.id);
204+
const workflowIds = workflows.map(w => w.id);
183205
// ---------------------- 4. Fetch Branches (if needed) ----------------------
184206
let branchNames = [];
185207
if (checkBranchExistence) {
@@ -188,8 +210,7 @@ async function run() {
188210
owner: repoOwner,
189211
repo: repoName,
190212
per_page: 100,
191-
})
192-
).map((b) => b.name);
213+
})).map(b => b.name);
193214
core.info(`💬 Found ${branchNames.length} branches`);
194215
}
195216
// ---------------------- 5. Delete Orphan Runs ----------------------
@@ -198,32 +219,38 @@ async function run() {
198219
repo: repoName,
199220
per_page: 100,
200221
});
201-
const orphanRuns = allRuns.filter((run) => !workflowIds.includes(run.workflow_id));
222+
const orphanRuns = allRuns.filter(run => !workflowIds.includes(run.workflow_id));
202223
if (orphanRuns.length > 0) {
203224
core.info(`👻 Found ${orphanRuns.length} orphan runs`);
204225
await deleteRuns(orphanRuns, "orphan runs", dryRun, octokit, repoOwner, repoName);
205226
}
206227
// ---------------------- 6. Filter Workflows ----------------------
207228
let filteredWorkflows = workflows;
208229
if (deleteWorkflowPattern) {
209-
const patterns = splitPattern(deleteWorkflowPattern);
230+
const patterns = splitPattern(deleteWorkflowPattern).map(p => p.toLowerCase());
210231
if (patterns.length > 0) {
211232
core.info(`🔍 Filtering by patterns: ${patterns.join(", ")}`);
212-
filteredWorkflows = filteredWorkflows.filter(({ name, path }) => {
213-
const filename = path.replace(/^\.github\/workflows\//, "");
214-
return patterns.some((p) => name.includes(p) || filename.includes(p));
233+
filteredWorkflows = filteredWorkflows.filter(({
234+
name,
235+
path
236+
}) => {
237+
const filename = (path || "").replace(/^\.github\/workflows\//, "");
238+
const nameLower = String(name || "").toLowerCase();
239+
const filenameLower = String(filename || "").toLowerCase();
240+
return patterns.some(p => nameLower.includes(p) || filenameLower.includes(p));
215241
});
216242
}
217243
}
218244
if (deleteWorkflowByStatePattern.toUpperCase() !== "ALL") {
219-
const states = splitPattern(deleteWorkflowByStatePattern);
245+
const states = splitPattern(deleteWorkflowByStatePattern).map(s => s.toLowerCase());
220246
core.info(`🔍 Filtering by state: ${states.join(", ")}`);
221-
filteredWorkflows = filteredWorkflows.filter(({ state }) => states.includes(state));
247+
filteredWorkflows = filteredWorkflows.filter(({
248+
state
249+
}) => states.includes(String(state ?? "").toLowerCase()));
222250
}
223251
core.info(`Processing ${filteredWorkflows.length} workflow(s)`);
224252
// ---------------------- 7. Process Each Workflow ----------------------
225-
const allowedConclusionsAll = deleteRunByConclusionPattern.toUpperCase() === "ALL";
226-
const allowedConclusions = allowedConclusionsAll ? [] : splitPattern(deleteRunByConclusionPattern);
253+
const allowedConclusions = deleteRunByConclusionPattern.toUpperCase() === "ALL" ? [] : splitPattern(deleteRunByConclusionPattern).map(c => c.toLowerCase());
227254
for (const workflow of filteredWorkflows) {
228255
core.startGroup(`Processing: ${workflow.name} (ID: ${workflow.id})`);
229256
const runs = await octokit.paginate(octokit.rest.actions.listWorkflowRuns, {
@@ -232,32 +259,29 @@ async function run() {
232259
workflow_id: workflow.id,
233260
per_page: 100,
234261
});
235-
const candidates = runs.filter((run) =>
262+
// Pre-filter (branch, PR, conclusion, etc.)
263+
const candidates = runs.filter(run =>
236264
shouldDeleteRun(run, {
237265
checkPullRequestExist,
238266
checkBranchExistence,
239267
branchNames,
240268
allowedConclusions,
241-
retainDays,
242-
}),
243-
);
269+
retainDays: useDailyRetention ? 0 : retainDays, // age handled later in daily mode
270+
skipAgeCheck: useDailyRetention,
271+
}),);
244272
let runsToDelete = [];
245273
let runsToRetain = [];
246274
if (useDailyRetention) {
247-
// Use daily retention strategy
248-
const { runsToDelete: dailyRunsToDelete, runsToRetain: dailyRunsToRetain } =
249-
filterRunsByDailyRetention(candidates, keepMinimumRuns);
250-
runsToDelete = dailyRunsToDelete;
251-
runsToRetain = dailyRunsToRetain;
252-
core.info(`📅 Daily retention: Keeping ${keepMinimumRuns} runs per day, retaining ${runsToRetain.length} runs total`);
275+
const { runsToDelete: del, runsToRetain: ret } = filterRunsByDailyRetention(candidates, keepMinimumRuns, retainDays);
276+
runsToDelete = del;
277+
runsToRetain = ret;
278+
core.info(`🔄 Daily retention: Keeping up to ${keepMinimumRuns} runs/day for last ${retainDays} days`);
253279
} else {
254-
// Use original strategy (keep latest N runs overall)
255280
candidates.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
256281
runsToRetain = keepMinimumRuns > 0 ? candidates.slice(-keepMinimumRuns) : [];
257-
runsToDelete = keepMinimumRuns > 0 ? candidates.slice(0, candidates.length - keepMinimumRuns) : candidates;
258-
if (runsToRetain.length > 0) {
282+
runsToDelete = keepMinimumRuns > 0 ? candidates.slice(0, candidates.length - runsToRetain.length) : candidates;
283+
if (runsToRetain.length > 0)
259284
core.info(`🔄 Retaining latest ${runsToRetain.length} run(s)`);
260-
}
261285
}
262286
if (runsToDelete.length > 0) {
263287
core.info(`🚀 Deleting ${runsToDelete.length} run(s)`);

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@
3232
},
3333
"dependencies": {
3434
"@actions/core": "^1.11.1",
35-
"@octokit/rest": "^22.0.0",
35+
"@octokit/rest": "^22.0.1",
3636
"@octokit/plugin-throttling": "^11.0.3"
3737
},
3838
"devDependencies": {
3939
"@vercel/ncc": "^0.38.4",
40-
"eslint": "^9.38.0",
40+
"eslint": "^9.39.1",
4141
"eslint-config-prettier": "^10.1.8",
4242
"jest": "^30.2.0",
4343
"prettier": "^3.6.2"

0 commit comments

Comments
 (0)