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
17 changes: 13 additions & 4 deletions .github/workflows/mcp-release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,20 @@ name: Package Release Please
# default, these four packages have genuinely independent release cadences and separate publish
# targets/workflows, and bundling them would work against this repo's small-focused-PR convention.
#
# Runs on a schedule rather than every push to main: release-please is idempotent (it recomputes
# purely from conventional-commit history since each component's last release tag), so batching has
# zero functional impact -- it only reduces how often the Release PR(s) refresh against this repo's
# push volume. workflow_dispatch stays available for an on-demand run.
# Runs on every push to main, not just a schedule: release-please is idempotent (it recomputes
# purely from conventional-commit history since each component's last release tag), so the extra
# runs cost nothing when there's no new commit or merged Release PR to act on. The push trigger is
# not just about refreshing the Release PR's proposed diff sooner -- it's what actually completes a
# release: merging a component's Release PR only tags it, creates the GitHub Release, and dispatches
# the matching publish workflow on release-please's THIS run, so without a push trigger a merge sits
# fully un-published until the next cron tick (confirmed live: mcp-v3.1.0's Release PR merged 8
# minutes after a cron run had already passed, leaving it tagged-in-package.json-but-unpublished for
# what would have been up to 2 days). The schedule stays as a redundant safety net in case a
# push-triggered run is ever skipped/fails; workflow_dispatch stays available for an on-demand run.
on:
push:
branches:
- main
schedule:
- cron: "0 16 */2 * *"
workflow_dispatch:
Expand Down
3 changes: 3 additions & 0 deletions scripts/check-mcp-release-due.d.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { McpReleaseReport } from "./mcp-release-core.d.mts";

export type GitHubIssueCandidate = {
pull_request?: unknown;
title?: unknown;
Expand All @@ -8,3 +10,4 @@ export type GitHubIssueCandidate = {
};

export function isReleaseWatchIssue(issue: GitHubIssueCandidate): boolean;
export function closeResolvedIssueIfPresent(report: McpReleaseReport): Promise<void>;
50 changes: 42 additions & 8 deletions scripts/check-mcp-release-due.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ async function main() {
const report = buildMcpReleaseReport({ latestTag, packageVersion, publishedVersion, commits });

if (args.output) writeFileSync(args.output, `${JSON.stringify(report, null, 2)}\n`);
if (args.upsertIssue && report.due) await upsertIssue(report);
if (args.upsertIssue) {
if (report.due) {
await upsertIssue(report);
} else {
await closeResolvedIssueIfPresent(report);
}
}
if (args.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (!args.json && !args.output) {
process.stdout.write(report.due ? `MCP release due: ${report.proposedVersion}\n` : "No MCP release due.\n");
Expand Down Expand Up @@ -77,13 +83,7 @@ function git(args) {
}

async function upsertIssue(report) {
const repository = process.env.GITHUB_REPOSITORY;
const token = process.env.GITHUB_TOKEN;
if (!repository) throw new Error("GITHUB_REPOSITORY is required for --upsert-issue");
if (!token) throw new Error("GITHUB_TOKEN is required for --upsert-issue");
const [owner, repo] = repository.split("/");
if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);

const { owner, repo, token } = resolveRepoAndToken();
const issue = buildMcpReleaseIssue(report);
const existingIssue = await findExistingIssue({ owner, repo, token });
if (existingIssue) {
Expand All @@ -106,6 +106,40 @@ async function upsertIssue(report) {
process.stdout.write(`Opened issue #${created.number}: ${issue.title}\n`);
}

// upsertIssue only ever opens/refreshes the tracking issue while a release is due -- without this,
// the issue it created is never closed once release-please catches up (confirmed live: #6145 stayed
// open and stale after mcp-v3.1.0's release-please PR merged, since no due=false run had ever closed it).
export async function closeResolvedIssueIfPresent(report) {
const { owner, repo, token } = resolveRepoAndToken();
const existingIssue = await findExistingIssue({ owner, repo, token });
if (!existingIssue) return;

const body = `MCP is caught up: latest tag \`${report.latestTag ?? "none"}\` matches the package version \`${report.packageVersion}\`, and npm's published version is \`${report.publishedVersion ?? "unknown"}\`, with no unreleased MCP-related commits. Closing -- release-please's own Release PR reopens this signal automatically if a new release becomes due.`;
await githubRequest({
token,
method: "POST",
path: `/repos/${owner}/${repo}/issues/${existingIssue.number}/comments`,
body: { body },
});
await githubRequest({
token,
method: "PATCH",
path: `/repos/${owner}/${repo}/issues/${existingIssue.number}`,
body: { state: "closed", state_reason: "completed" },
});
process.stdout.write(`Closed issue #${existingIssue.number}: release caught up.\n`);
}

function resolveRepoAndToken() {
const repository = process.env.GITHUB_REPOSITORY;
const token = process.env.GITHUB_TOKEN;
if (!repository) throw new Error("GITHUB_REPOSITORY is required for --upsert-issue");
if (!token) throw new Error("GITHUB_TOKEN is required for --upsert-issue");
const [owner, repo] = repository.split("/");
if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);
return { owner, repo, token };
}

async function findExistingIssue({ owner, repo, token }) {
let page = 1;
while (page <= 10) {
Expand Down
95 changes: 93 additions & 2 deletions test/unit/mcp-release.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isReleaseWatchIssue } from "../../scripts/check-mcp-release-due.mjs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { closeResolvedIssueIfPresent, isReleaseWatchIssue } from "../../scripts/check-mcp-release-due.mjs";
import { buildMcpReleaseIssue, buildMcpReleaseReport, renderMcpChangelog, selectMcpReleaseCommits } from "../../scripts/mcp-release-core.mjs";

type TestCommit = {
Expand Down Expand Up @@ -117,3 +117,94 @@ describe("MCP release changelog detection", () => {
).toBe(false);
});
});

describe("closeResolvedIssueIfPresent (#6145 follow-up)", () => {
const resolvedReport = {
due: false,
proposedVersion: "3.1.0",
latestTag: "mcp-v3.1.0",
latestTagVersion: "3.1.0",
packageVersion: "3.1.0",
publishedVersion: "3.1.0",
releaseType: null,
commits: [],
changedFiles: [],
};

afterEach(() => {
vi.unstubAllGlobals();
delete process.env.GITHUB_REPOSITORY;
delete process.env.GITHUB_TOKEN;
});

it("comments and closes an existing open watch issue once the release has caught up", async () => {
process.env.GITHUB_REPOSITORY = "acme/widgets";
process.env.GITHUB_TOKEN = "test-token";
const calls: Array<{ method: string; url: string; body: unknown }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const method = init?.method ?? "GET";
calls.push({ method, url: String(input), body: init?.body ? JSON.parse(init.body as string) : undefined });
if (method === "GET") {
return new Response(
JSON.stringify([
{
number: 6145,
title: "MCP release due: 4.0.0",
body: "<!-- loopover:mcp-release-due -->",
user: { login: "github-actions[bot]" },
},
]),
{ status: 200 },
);
}
return new Response("{}", { status: 200 });
}),
);

await closeResolvedIssueIfPresent(resolvedReport);

expect(calls).toHaveLength(3);
const commentCall = calls.find((call) => call.method === "POST" && call.url.includes("/comments"));
const patchCall = calls.find((call) => call.method === "PATCH");
expect(commentCall?.url).toContain("/issues/6145/comments");
expect(commentCall?.body).toMatchObject({ body: expect.stringContaining("caught up") });
expect(patchCall?.url).toContain("/issues/6145");
expect(patchCall?.body).toEqual({ state: "closed", state_reason: "completed" });
});

it("does nothing when no open watch issue exists", async () => {
process.env.GITHUB_REPOSITORY = "acme/widgets";
process.env.GITHUB_TOKEN = "test-token";
const fetchMock = vi.fn(async () => new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);

await closeResolvedIssueIfPresent(resolvedReport);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("ignores an open issue authored by someone other than github-actions[bot]", async () => {
process.env.GITHUB_REPOSITORY = "acme/widgets";
process.env.GITHUB_TOKEN = "test-token";
// findExistingIssue pages until an empty page ends the search -- page 1 returns a non-matching
// issue, page 2 must come back empty or the (real) pagination loop keeps requesting pages 3..10.
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify([
{ number: 9, title: "MCP release due: 4.0.0", body: "<!-- loopover:mcp-release-due -->", user: { login: "someone-else" } },
]),
{ status: 200 },
),
)
.mockResolvedValue(new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);

await closeResolvedIssueIfPresent(resolvedReport);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});