diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 8dcaf02..926de94 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,6 +15,7 @@ jobs: release-please: runs-on: ubuntu-latest permissions: + actions: write # restart checks after GITHUB_TOKEN updates release PR branches contents: write # create release tags, GitHub releases, and the release PR pull-requests: write # open/update the release PR outputs: diff --git a/packages/release-please-ai/src/check-reruns.ts b/packages/release-please-ai/src/check-reruns.ts new file mode 100644 index 0000000..43a69d8 --- /dev/null +++ b/packages/release-please-ai/src/check-reruns.ts @@ -0,0 +1,86 @@ +import type { PullRequest } from "release-please"; +import type { GitHubApi } from "release-please/build/src/github-api.js"; + +type CheckRerunClient = { + pulls: Pick; + actions: Pick; +}; + +interface RetryOptions { + attempts?: number; + delayMs?: number; + sleep?: (delayMs: number) => Promise; +} + +const DEFAULT_ATTEMPTS = 15; +const DEFAULT_DELAY_MS = 1_000; + +export async function rerunActionRequiredChecks( + client: CheckRerunClient, + repository: { owner: string; repo: string }, + pullRequests: ReadonlyArray>, + options: RetryOptions = {}, +): Promise { + if (pullRequests.length === 0) { + return 0; + } + if (!repository.owner || !repository.repo) { + throw new Error("GitHub repository owner and name are required"); + } + + const attempts = options.attempts ?? DEFAULT_ATTEMPTS; + const delayMs = options.delayMs ?? DEFAULT_DELAY_MS; + const sleep = + options.sleep ?? ((delay: number) => new Promise((resolve) => setTimeout(resolve, delay))); + if (!Number.isInteger(attempts) || attempts < 1) { + throw new Error(`attempts must be a positive integer, got: ${attempts}`); + } + if (!Number.isFinite(delayMs) || delayMs < 0) { + throw new Error(`delayMs must be non-negative, got: ${delayMs}`); + } + + const targets = await Promise.all( + pullRequests.map(async (pullRequest) => { + if (!pullRequest.headBranchName) { + throw new Error(`Release pull request #${pullRequest.number} has no head branch`); + } + const response = await client.pulls.get({ + ...repository, + pull_number: pullRequest.number, + }); + const sha = response.data.head.sha; + if (!sha) { + throw new Error(`Release pull request #${pullRequest.number} has no head SHA`); + } + return { branch: pullRequest.headBranchName, sha }; + }), + ); + + const rerunIds = new Set(); + for (let attempt = 0; attempt < attempts; attempt++) { + for (const target of targets) { + const response = await client.actions.listWorkflowRunsForRepo({ + ...repository, + event: "pull_request", + branch: target.branch, + per_page: 20, + }); + for (const run of response.data.workflow_runs) { + if ( + run.head_sha !== target.sha || + run.conclusion !== "action_required" || + rerunIds.has(run.id) + ) { + continue; + } + await client.actions.reRunWorkflow({ ...repository, run_id: run.id }); + rerunIds.add(run.id); + } + } + if (attempt + 1 < attempts) { + await sleep(delayMs); + } + } + + return rerunIds.size; +} diff --git a/packages/release-please-ai/src/cli.ts b/packages/release-please-ai/src/cli.ts index 84128a1..e185b98 100644 --- a/packages/release-please-ai/src/cli.ts +++ b/packages/release-please-ai/src/cli.ts @@ -1,7 +1,8 @@ import { appendFileSync } from "node:fs"; import { GitHub, Manifest } from "release-please"; -import type { CreatedRelease } from "release-please"; +import type { CreatedRelease, PullRequest } from "release-please"; import { registerAiChangelogNotes } from "./changelog-notes.js"; +import { rerunActionRequiredChecks } from "./check-reruns.js"; function required(name: string): string { const value = process.env[name]; @@ -36,8 +37,16 @@ async function main(): Promise { const result = await runReleasePlease(() => Manifest.fromManifest(github, targetBranch, configFile, manifestFile), ); + const rerunCount = await rerunActionRequiredChecks( + github.getGitHubApi().octokit, + { owner, repo }, + result.pullRequests, + ); + if (rerunCount > 0) { + process.stderr.write(`release-please: restarted ${rerunCount} release PR check run(s)\n`); + } - emitOutputs(result.pullRequestCount, result.releases); + emitOutputs(result.pullRequests.length, result.releases); } interface ReleasePleaseManifest { @@ -47,7 +56,7 @@ interface ReleasePleaseManifest { export async function runReleasePlease( loadManifest: () => Promise, -): Promise<{ pullRequestCount: number; releases: CreatedRelease[] }> { +): Promise<{ pullRequests: PullRequest[]; releases: CreatedRelease[] }> { const releaseManifest = await loadManifest(); const releases = (await releaseManifest.createReleases()).filter( (release): release is CreatedRelease => Boolean(release), @@ -56,9 +65,11 @@ export async function runReleasePlease( // Reload after tagging merged releases. Otherwise createPullRequests sees the // just-merged release PR as untagged and aborts before refreshing sibling PRs. const pullRequestManifest = await loadManifest(); - const pullRequests = (await pullRequestManifest.createPullRequests()).filter(Boolean); + const pullRequests = (await pullRequestManifest.createPullRequests()).filter( + (pullRequest): pullRequest is PullRequest => Boolean(pullRequest), + ); - return { pullRequestCount: pullRequests.length, releases }; + return { pullRequests, releases }; } /** Write GitHub Actions step outputs (mirrors googleapis/release-please-action). */ diff --git a/packages/release-please-ai/test/unit/check-reruns.test.ts b/packages/release-please-ai/test/unit/check-reruns.test.ts new file mode 100644 index 0000000..c9f6c37 --- /dev/null +++ b/packages/release-please-ai/test/unit/check-reruns.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { rerunActionRequiredChecks } from "../../src/check-reruns.js"; + +describe("rerunActionRequiredChecks", () => { + it("reruns action-required checks for the current release PR head", async () => { + const get = vi.fn(async () => ({ data: { head: { sha: "current-sha" } } })); + const listWorkflowRunsForRepo = vi.fn(async () => ({ + data: { + workflow_runs: [ + { id: 1, head_sha: "current-sha", conclusion: "action_required" }, + { id: 2, head_sha: "current-sha", conclusion: "action_required" }, + { id: 3, head_sha: "current-sha", conclusion: "success" }, + { id: 4, head_sha: "stale-sha", conclusion: "action_required" }, + ], + }, + })); + const reRunWorkflow = vi.fn(async () => ({})); + const sleep = vi.fn(async () => undefined); + + const count = await rerunActionRequiredChecks( + { + pulls: { get }, + actions: { listWorkflowRunsForRepo, reRunWorkflow }, + } as never, + { owner: "coder", repo: "ai-sdk" }, + [{ number: 27, headBranchName: "release-please--sandbox" }], + { attempts: 2, delayMs: 0, sleep }, + ); + + expect(get).toHaveBeenCalledWith({ owner: "coder", repo: "ai-sdk", pull_number: 27 }); + expect(listWorkflowRunsForRepo).toHaveBeenCalledTimes(2); + expect(reRunWorkflow.mock.calls).toEqual([ + [{ owner: "coder", repo: "ai-sdk", run_id: 1 }], + [{ owner: "coder", repo: "ai-sdk", run_id: 2 }], + ]); + expect(sleep).toHaveBeenCalledOnce(); + expect(count).toBe(2); + }); + + it("does not query GitHub when no release PR was updated", async () => { + const get = vi.fn(); + const listWorkflowRunsForRepo = vi.fn(); + const reRunWorkflow = vi.fn(); + + await expect( + rerunActionRequiredChecks( + { + pulls: { get }, + actions: { listWorkflowRunsForRepo, reRunWorkflow }, + } as never, + { owner: "coder", repo: "ai-sdk" }, + [], + ), + ).resolves.toBe(0); + expect(get).not.toHaveBeenCalled(); + expect(listWorkflowRunsForRepo).not.toHaveBeenCalled(); + expect(reRunWorkflow).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/release-please-ai/test/unit/cli.test.ts b/packages/release-please-ai/test/unit/cli.test.ts index c046c6c..706a3b2 100644 --- a/packages/release-please-ai/test/unit/cli.test.ts +++ b/packages/release-please-ai/test/unit/cli.test.ts @@ -33,6 +33,6 @@ describe("runReleasePlease", () => { expect(calls).toEqual(["createReleases", "createPullRequests"]); expect(loadManifest).toHaveBeenCalledTimes(2); - expect(result).toEqual({ pullRequestCount: 1, releases: [release] }); + expect(result).toEqual({ pullRequests: [{ number: 27 }], releases: [release] }); }); });