diff --git a/.github/workflows/mcp-release-please.yml b/.github/workflows/mcp-release-please.yml index 5b6a77cf9..60ed97b42 100644 --- a/.github/workflows/mcp-release-please.yml +++ b/.github/workflows/mcp-release-please.yml @@ -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: diff --git a/scripts/check-mcp-release-due.d.mts b/scripts/check-mcp-release-due.d.mts index 7030bb0dc..1cb2c67a9 100644 --- a/scripts/check-mcp-release-due.d.mts +++ b/scripts/check-mcp-release-due.d.mts @@ -1,3 +1,5 @@ +import type { McpReleaseReport } from "./mcp-release-core.d.mts"; + export type GitHubIssueCandidate = { pull_request?: unknown; title?: unknown; @@ -8,3 +10,4 @@ export type GitHubIssueCandidate = { }; export function isReleaseWatchIssue(issue: GitHubIssueCandidate): boolean; +export function closeResolvedIssueIfPresent(report: McpReleaseReport): Promise; diff --git a/scripts/check-mcp-release-due.mjs b/scripts/check-mcp-release-due.mjs index 0aa6d3a92..4bd022c36 100644 --- a/scripts/check-mcp-release-due.mjs +++ b/scripts/check-mcp-release-due.mjs @@ -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"); @@ -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) { @@ -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) { diff --git a/test/unit/mcp-release.test.ts b/test/unit/mcp-release.test.ts index cb28be2c2..9a6e2e139 100644 --- a/test/unit/mcp-release.test.ts +++ b/test/unit/mcp-release.test.ts @@ -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 = { @@ -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: "", + 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: "", user: { login: "someone-else" } }, + ]), + { status: 200 }, + ), + ) + .mockResolvedValue(new Response("[]", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await closeResolvedIssueIfPresent(resolvedReport); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +});