Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
86 changes: 86 additions & 0 deletions packages/release-please-ai/src/check-reruns.ts
Original file line number Diff line number Diff line change
@@ -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<GitHubApi["octokit"]["pulls"], "get">;
actions: Pick<GitHubApi["octokit"]["actions"], "listWorkflowRunsForRepo" | "reRunWorkflow">;
};

interface RetryOptions {
attempts?: number;
delayMs?: number;
sleep?: (delayMs: number) => Promise<void>;
}

const DEFAULT_ATTEMPTS = 15;
const DEFAULT_DELAY_MS = 1_000;

export async function rerunActionRequiredChecks(
client: CheckRerunClient,
repository: { owner: string; repo: string },
pullRequests: ReadonlyArray<Pick<PullRequest, "number" | "headBranchName">>,
options: RetryOptions = {},
): Promise<number> {
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<number>();
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;
}
21 changes: 16 additions & 5 deletions packages/release-please-ai/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand Down Expand Up @@ -36,8 +37,16 @@ async function main(): Promise<void> {
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 {
Expand All @@ -47,7 +56,7 @@ interface ReleasePleaseManifest {

export async function runReleasePlease(
loadManifest: () => Promise<ReleasePleaseManifest>,
): 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),
Expand All @@ -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). */
Expand Down
59 changes: 59 additions & 0 deletions packages/release-please-ai/test/unit/check-reruns.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 1 addition & 1 deletion packages/release-please-ai/test/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
});
});