/// <summary> /// <para> /// Deletes workflow runs from a GitHub repo. /// </para> /// <note> /// Only completed runs will be deleted. /// </note> /// </summary> /// <param name="repo">Identifies the target repository.</param> /// <param name="workflowName"> /// Optionally specifies the workflow whose runs are to be deleted otherwise /// runs from all workflows in the repo will be deleted. /// </param> /// <param name="maxAge"> /// Optionally specifies the maximum age for retained workflow runs. This /// defaults to <see cref="TimeSpan.Zero"/> which deletes all runs. /// </param> /// <returns>The number of runs deleted.</returns> public async Task <int> DeleteRunsAsync(string repo, string workflowName = null, TimeSpan maxAge = default) { await SyncContext.Clear; GitHub.GetCredentials(); var repoPath = GitHubRepoPath.Parse(repo); var deleteCount = 0; using (var client = new HttpClient()) { var retry = new ExponentialRetryPolicy(TransientDetector.NetworkOrHttp, 5); client.BaseAddress = new Uri("https://api.github.com"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", GitHub.AccessToken); client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("neonforge.com", "0")); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.v3+json")); // List all of the workflow runs for the repo, paging to get all of them. // // https://docs.github.com/en/rest/reference/actions#list-workflow-runs-for-a-repository var runs = new List <RunInfo>(); var page = 1; while (true) { var response = await retry.InvokeAsync( async() => { var request = new HttpRequestMessage(HttpMethod.Get, $"/repos/{repoPath.Owner}/{repoPath.Repo}/actions/runs?page={page}"); // We're seeing some 502 Bad Gateway responses from GHCR.io. We're going to // treat these as transients. var response = await client.SendAsync(request); if (response.StatusCode == HttpStatusCode.BadGateway) { throw new TransientException("503 (Bad Gateway)"); } return(response); }); response.EnsureSuccessStatusCode(); var json = response.Content.ReadAsStringAsync().Result; var result = JsonConvert.DeserializeObject <dynamic>(json); var workflowRuns = result.workflow_runs; if (workflowRuns.Count == 0) { // We've seen all of the runs. break; } foreach (var run in workflowRuns) { runs.Add( new RunInfo() { Id = run.id, Name = run.name, Status = run.status, UpdatedAtUtc = run.updated_at }); } page++; } // Here's the reference for deleting runs: // // https://docs.github.com/en/rest/reference/actions#delete-a-workflow-run var minDate = DateTime.UtcNow - maxAge; var selectedRuns = runs.Where(run => run.UpdatedAtUtc < minDate && run.Status == "completed"); if (!string.IsNullOrEmpty(workflowName)) { selectedRuns = selectedRuns.Where(run => run.Name.Equals(workflowName, StringComparison.InvariantCultureIgnoreCase)); } foreach (var run in selectedRuns) { var response = await retry.InvokeAsync( async() => { var request = new HttpRequestMessage(HttpMethod.Delete, $"/repos/{repoPath.Owner}/{repoPath.Repo}/actions/runs/{run.Id}"); return(await client.SendAsync(request)); }); // We're also seeing some 500s but I'm not sure why. We'll ignore these // for now. if (response.StatusCode == HttpStatusCode.InternalServerError) { Task.Delay(TimeSpan.FromSeconds(2)).WaitWithoutAggregate(); // Pause in case this is a rate-limit thing continue; } // We're seeing 403s for some runs, so we'll ignore those too. if (response.StatusCode != HttpStatusCode.Forbidden) { response.EnsureSuccessStatusCode(); } deleteCount++; } } return(deleteCount); }