/// <summary> /// Uploads an asset file to a GitHub release. Any existing asset with same name will be replaced. /// </summary> /// <param name="repo">Identifies the target repository.</param> /// <param name="release">The target release.</param> /// <param name="assetPath">Path to the source asset file.</param> /// <param name="assetName">Optionally specifies the file name to assign to the asset. This defaults to the file name in <paramref name="assetPath"/>.</param> /// <param name="contentType">Optionally specifies the asset's <b>Content-Type</b>. This defaults to: <b> application/octet-stream</b></param> /// <returns>The new <see cref="ReleaseAsset"/>.</returns> /// <exception cref="NotSupportedException">Thrown when the releas has already been published.</exception> /// <remarks> /// <note> /// The current implementation only works for unpublished releases where <c>Draft=true</c>. /// </note> /// </remarks> public ReleaseAsset UploadAsset(string repo, Release release, string assetPath, string assetName = null, string contentType = "application/octet-stream") { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(repo), nameof(repo)); Covenant.Requires <ArgumentNullException>(release != null, nameof(release)); Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(assetPath), nameof(assetPath)); if (!release.Draft) { throw new NotSupportedException("Cannot upload asset to already published release."); } var repoPath = GitHubRepoPath.Parse(repo); var client = GitHub.CreateGitHubClient(repo); using (var assetStream = File.OpenRead(assetPath)) { if (string.IsNullOrEmpty(assetName)) { assetName = Path.GetFileName(assetPath); } var upload = new ReleaseAssetUpload() { FileName = assetName, ContentType = contentType, RawData = assetStream }; return(client.Repository.Release.UploadAsset(release, upload).Result); } }
/// <summary> /// Creates a GitHub release. /// </summary> /// <param name="repo">Identifies the target repo.</param> /// <param name="tagName">Specifies the tag to be referenced by the release.</param> /// <param name="releaseName">Optionally specifies the release name (defaults to <paramref name="tagName"/>).</param> /// <param name="body">Optionally specifies the markdown formatted release notes.</param> /// <param name="draft">Optionally indicates that the release won't be published immediately.</param> /// <param name="prerelease">Optionally indicates that the release is not production ready.</param> /// <param name="branch">Optionally identifies the branch to be tagged. This defaults to <b>master</b> or <b>main</b> when either of those branches are already present.</param> /// <returns>The newly created <see cref="Release"/>.</returns> /// <remarks> /// <para> /// If the <paramref name="tagName"/> doesn't already exist in the repo, this method will /// tag the latest commit on the specified <paramref name="branch"/> or else the defailt branch /// in the target repo and before creating the release. /// </para> /// </remarks> public Release Create(string repo, string tagName, string releaseName = null, string body = null, bool draft = false, bool prerelease = false, string branch = null) { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(repo), nameof(repo)); Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(tagName), nameof(tagName)); releaseName = releaseName ?? tagName; var repoPath = GitHubRepoPath.Parse(repo); var client = GitHub.CreateGitHubClient(repo); var tags = client.Repository.GetAllTags(repoPath.Owner, repoPath.Repo).Result; var tag = tags.SingleOrDefault(tag => tag.Name == tagName); // Tag the specified or default branch when the tag doesn't already exist. // Note that we may need to if (tag == null) { if (string.IsNullOrEmpty(branch)) { // Identify the default branch. var branches = client.Repository.Branch.GetAll(repoPath.Owner, repoPath.Repo).Result; foreach (var branchDetails in branches) { if (branchDetails.Name == "master") { branch = "master"; break; } else if (branchDetails.Name == "main") { branch = "main"; break; } } var newTag = new NewTag() { Message = $"release-tag: {tagName}", Tag = tagName, Object = "", }; client.Git.Tag.Create(repoPath.Owner, repoPath.Repo, newTag); } } // Create the release. var release = new NewRelease(tagName) { Name = releaseName, Draft = draft, Prerelease = prerelease, Body = body }; return(client.Repository.Release.Create(repoPath.Owner, repoPath.Repo, release).Result); }
/// <summary> /// List the releases for a GitHub repo. /// </summary> /// <returns>The list of releases.</returns> public IReadOnlyList <Release> List(string repo) { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(repo), nameof(repo)); var repoPath = GitHubRepoPath.Parse(repo); var client = GitHub.CreateGitHubClient(repo); return(client.Repository.Release.GetAll(repoPath.Owner, repoPath.Repo).Result); }
/// <summary> /// Deletes a GitHub release. /// </summary> /// <param name="repo">Identifies the target repository.</param> /// <param name="release">The target release.</param> /// <remarks> /// <note> /// This fails silently if the release doesn't exist. /// </note> /// </remarks> public void Remove(string repo, Release release) { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(repo), nameof(repo)); Covenant.Requires <ArgumentNullException>(release != null, nameof(release)); var repoPath = GitHubRepoPath.Parse(repo); var client = GitHub.CreateGitHubClient(repo); client.Repository.Release.Delete(repoPath.Owner, repoPath.Repo, release.Id).WaitWithoutAggregate(); }
/// <summary> /// Returns the releases that satisfies a predicate. /// </summary> /// <param name="repo">Identifies the target repository.</param> /// <param name="predicate">The predicate.</param> /// <returns>The list of matching releases.</returns> public List <Release> Find(string repo, Func <Release, bool> predicate) { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(repo), nameof(repo)); Covenant.Requires <ArgumentNullException>(predicate != null, nameof(predicate)); var repoPath = GitHubRepoPath.Parse(repo); var client = GitHub.CreateGitHubClient(repo); return(List(repo).Where(predicate).ToList()); }
/// <summary> /// Creates a REST client that can be used to manage GitHub. /// </summary> /// <param name="repo">Identifies the target repo.</param> /// <returns>The <see cref="GitHubClient"/> instance.</returns> internal static GitHubClient CreateGitHubClient(string repo) { GitHub.GetCredentials(); var repoPath = GitHubRepoPath.Parse(repo); var client = new GitHubClient(new Octokit.ProductHeaderValue("neonkube.com")); // $todo(jefflill): https://github.com/nforgeio/neonKUBE/issues/1214 client.Credentials = new Octokit.Credentials(AccessToken); return(client); }
/// <summary> /// Updates a GitHub release. /// </summary> /// <param name="repo">Identifies the target repository.</param> /// <param name="release">Specifies the release being updated.</param> /// <param name="releaseUpdate">Specifies the revisions.</param> /// <returns>The updated release.</returns> /// <remarks> /// <para> /// To update a release, you'll first need to: /// </para> /// <list type="number"> /// <item> /// Obtain a <see cref="Release"/> referencing the target release returned from /// <see cref="Create(string, string, string, string, bool, bool, string)"/> /// or by listing or getting releases. /// </item> /// <item> /// Obtain a <see cref="ReleaseUpdate"/> by calling <see cref="Release.ToUpdate"/>. /// </item> /// <item> /// Make your changes to the release update. /// </item> /// <item> /// Call <see cref="Update(string, Release, ReleaseUpdate)"/>, passing the /// original release along with the update. /// </item> /// </list> /// </remarks> public Release Update(string repo, Release release, ReleaseUpdate releaseUpdate) { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(repo), nameof(repo)); Covenant.Requires <ArgumentNullException>(release != null, nameof(release)); Covenant.Requires <ArgumentNullException>(releaseUpdate != null, nameof(releaseUpdate)); var repoPath = GitHubRepoPath.Parse(repo); var client = GitHub.CreateGitHubClient(repo); return(client.Repository.Release.Edit(repoPath.Owner, repoPath.Repo, release.Id, releaseUpdate).Result); }
/// <summary> /// Uploads an asset stream to a GitHub release. Any existing asset with same name will be replaced. /// </summary> /// <param name="repo">Identifies the target repository.</param> /// <param name="release">The target release.</param> /// <param name="assetStream">The asset source stream.</param> /// <param name="assetName">Specifies the file name to assign to the asset.</param> /// <param name="contentType">Optionally specifies the asset's <b>Content-Type</b>. This defaults to: <b> application/octet-stream</b></param> /// <returns>The new <see cref="ReleaseAsset"/>.</returns> public ReleaseAsset UploadAsset(string repo, Release release, Stream assetStream, string assetName, string contentType = "application/octet-stream") { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(repo), nameof(repo)); Covenant.Requires <ArgumentNullException>(release != null, nameof(release)); Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(assetName), nameof(assetName)); Covenant.Requires <ArgumentNullException>(assetStream != null, nameof(assetStream)); var repoPath = GitHubRepoPath.Parse(repo); var client = GitHub.CreateGitHubClient(repo); var upload = new ReleaseAssetUpload() { FileName = assetName, ContentType = contentType, RawData = assetStream }; return(client.Repository.Release.UploadAsset(release, upload).Result); }
/// <summary> /// Retrieves a specific GitHub release. /// </summary> /// <param name="repo">Identifies the target repository.</param> /// <param name="tagName">Specifies the tag for the target release.</param> /// <returns>The release information or <c>null</c> when the requested release doesn't exist.</returns> public Release Get(string repo, string tagName) { Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(repo), nameof(repo)); Covenant.Requires <ArgumentNullException>(!string.IsNullOrEmpty(tagName), nameof(tagName)); var repoPath = GitHubRepoPath.Parse(repo); var client = GitHub.CreateGitHubClient(repo); try { return(client.Repository.Release.Get(repoPath.Owner, repoPath.Repo, tagName).Result); } catch (Exception e) { if (e.Find <NotFoundException>() != null) { return(null); } throw; } }
/// <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); }