Skip to content

Commit c8d28b5

Browse files
committed
fix(release): complete MCP/engine/miner/ui-kit releases on merge, not on a 2-day tick
mcp-release-please.yml only ran on a cron + manual dispatch, so merging a component's release-please PR didn't tag/publish it until the next tick -- confirmed live: mcp-v3.1.0's release PR merged 8 minutes after the day's cron run had already passed, leaving it stuck package.json-bumped-but- unpublished. Add a push-to-main trigger so a merge completes immediately; release-please recomputes idempotently so the extra runs are free when there's nothing to do. Also fix check-mcp-release-due.mjs: it could open/refresh the "MCP release due" tracking issue but never closes it once release-please catches up, so any release leaves a permanently stale zombie issue behind (#6145).
1 parent 6586c64 commit c8d28b5

4 files changed

Lines changed: 151 additions & 14 deletions

File tree

.github/workflows/mcp-release-please.yml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,20 @@ name: Package Release Please
1616
# default, these four packages have genuinely independent release cadences and separate publish
1717
# targets/workflows, and bundling them would work against this repo's small-focused-PR convention.
1818
#
19-
# Runs on a schedule rather than every push to main: release-please is idempotent (it recomputes
20-
# purely from conventional-commit history since each component's last release tag), so batching has
21-
# zero functional impact -- it only reduces how often the Release PR(s) refresh against this repo's
22-
# push volume. workflow_dispatch stays available for an on-demand run.
19+
# Runs on every push to main, not just a schedule: release-please is idempotent (it recomputes
20+
# purely from conventional-commit history since each component's last release tag), so the extra
21+
# runs cost nothing when there's no new commit or merged Release PR to act on. The push trigger is
22+
# not just about refreshing the Release PR's proposed diff sooner -- it's what actually completes a
23+
# release: merging a component's Release PR only tags it, creates the GitHub Release, and dispatches
24+
# the matching publish workflow on release-please's THIS run, so without a push trigger a merge sits
25+
# fully un-published until the next cron tick (confirmed live: mcp-v3.1.0's Release PR merged 8
26+
# minutes after a cron run had already passed, leaving it tagged-in-package.json-but-unpublished for
27+
# what would have been up to 2 days). The schedule stays as a redundant safety net in case a
28+
# push-triggered run is ever skipped/fails; workflow_dispatch stays available for an on-demand run.
2329
on:
30+
push:
31+
branches:
32+
- main
2433
schedule:
2534
- cron: "0 16 */2 * *"
2635
workflow_dispatch:

scripts/check-mcp-release-due.d.mts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { McpReleaseReport } from "./mcp-release-core.d.mts";
2+
13
export type GitHubIssueCandidate = {
24
pull_request?: unknown;
35
title?: unknown;
@@ -8,3 +10,4 @@ export type GitHubIssueCandidate = {
810
};
911

1012
export function isReleaseWatchIssue(issue: GitHubIssueCandidate): boolean;
13+
export function closeResolvedIssueIfPresent(report: McpReleaseReport): Promise<void>;

scripts/check-mcp-release-due.mjs

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ async function main() {
1515
const report = buildMcpReleaseReport({ latestTag, packageVersion, publishedVersion, commits });
1616

1717
if (args.output) writeFileSync(args.output, `${JSON.stringify(report, null, 2)}\n`);
18-
if (args.upsertIssue && report.due) await upsertIssue(report);
18+
if (args.upsertIssue) {
19+
if (report.due) {
20+
await upsertIssue(report);
21+
} else {
22+
await closeResolvedIssueIfPresent(report);
23+
}
24+
}
1925
if (args.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
2026
if (!args.json && !args.output) {
2127
process.stdout.write(report.due ? `MCP release due: ${report.proposedVersion}\n` : "No MCP release due.\n");
@@ -77,13 +83,7 @@ function git(args) {
7783
}
7884

7985
async function upsertIssue(report) {
80-
const repository = process.env.GITHUB_REPOSITORY;
81-
const token = process.env.GITHUB_TOKEN;
82-
if (!repository) throw new Error("GITHUB_REPOSITORY is required for --upsert-issue");
83-
if (!token) throw new Error("GITHUB_TOKEN is required for --upsert-issue");
84-
const [owner, repo] = repository.split("/");
85-
if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);
86-
86+
const { owner, repo, token } = resolveRepoAndToken();
8787
const issue = buildMcpReleaseIssue(report);
8888
const existingIssue = await findExistingIssue({ owner, repo, token });
8989
if (existingIssue) {
@@ -106,6 +106,40 @@ async function upsertIssue(report) {
106106
process.stdout.write(`Opened issue #${created.number}: ${issue.title}\n`);
107107
}
108108

109+
// upsertIssue only ever opens/refreshes the tracking issue while a release is due -- without this,
110+
// the issue it created is never closed once release-please catches up (confirmed live: #6145 stayed
111+
// open and stale after mcp-v3.1.0's release-please PR merged, since no due=false run had ever closed it).
112+
export async function closeResolvedIssueIfPresent(report) {
113+
const { owner, repo, token } = resolveRepoAndToken();
114+
const existingIssue = await findExistingIssue({ owner, repo, token });
115+
if (!existingIssue) return;
116+
117+
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.`;
118+
await githubRequest({
119+
token,
120+
method: "POST",
121+
path: `/repos/${owner}/${repo}/issues/${existingIssue.number}/comments`,
122+
body: { body },
123+
});
124+
await githubRequest({
125+
token,
126+
method: "PATCH",
127+
path: `/repos/${owner}/${repo}/issues/${existingIssue.number}`,
128+
body: { state: "closed", state_reason: "completed" },
129+
});
130+
process.stdout.write(`Closed issue #${existingIssue.number}: release caught up.\n`);
131+
}
132+
133+
function resolveRepoAndToken() {
134+
const repository = process.env.GITHUB_REPOSITORY;
135+
const token = process.env.GITHUB_TOKEN;
136+
if (!repository) throw new Error("GITHUB_REPOSITORY is required for --upsert-issue");
137+
if (!token) throw new Error("GITHUB_TOKEN is required for --upsert-issue");
138+
const [owner, repo] = repository.split("/");
139+
if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);
140+
return { owner, repo, token };
141+
}
142+
109143
async function findExistingIssue({ owner, repo, token }) {
110144
let page = 1;
111145
while (page <= 10) {

test/unit/mcp-release.test.ts

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, expect, it } from "vitest";
2-
import { isReleaseWatchIssue } from "../../scripts/check-mcp-release-due.mjs";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { closeResolvedIssueIfPresent, isReleaseWatchIssue } from "../../scripts/check-mcp-release-due.mjs";
33
import { buildMcpReleaseIssue, buildMcpReleaseReport, renderMcpChangelog, selectMcpReleaseCommits } from "../../scripts/mcp-release-core.mjs";
44

55
type TestCommit = {
@@ -117,3 +117,94 @@ describe("MCP release changelog detection", () => {
117117
).toBe(false);
118118
});
119119
});
120+
121+
describe("closeResolvedIssueIfPresent (#6145 follow-up)", () => {
122+
const resolvedReport = {
123+
due: false,
124+
proposedVersion: "3.1.0",
125+
latestTag: "mcp-v3.1.0",
126+
latestTagVersion: "3.1.0",
127+
packageVersion: "3.1.0",
128+
publishedVersion: "3.1.0",
129+
releaseType: null,
130+
commits: [],
131+
changedFiles: [],
132+
};
133+
134+
afterEach(() => {
135+
vi.unstubAllGlobals();
136+
delete process.env.GITHUB_REPOSITORY;
137+
delete process.env.GITHUB_TOKEN;
138+
});
139+
140+
it("comments and closes an existing open watch issue once the release has caught up", async () => {
141+
process.env.GITHUB_REPOSITORY = "acme/widgets";
142+
process.env.GITHUB_TOKEN = "test-token";
143+
const calls: Array<{ method: string; url: string; body: unknown }> = [];
144+
vi.stubGlobal(
145+
"fetch",
146+
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
147+
const method = init?.method ?? "GET";
148+
calls.push({ method, url: String(input), body: init?.body ? JSON.parse(init.body as string) : undefined });
149+
if (method === "GET") {
150+
return new Response(
151+
JSON.stringify([
152+
{
153+
number: 6145,
154+
title: "MCP release due: 4.0.0",
155+
body: "<!-- loopover:mcp-release-due -->",
156+
user: { login: "github-actions[bot]" },
157+
},
158+
]),
159+
{ status: 200 },
160+
);
161+
}
162+
return new Response("{}", { status: 200 });
163+
}),
164+
);
165+
166+
await closeResolvedIssueIfPresent(resolvedReport);
167+
168+
expect(calls).toHaveLength(3);
169+
const commentCall = calls.find((call) => call.method === "POST" && call.url.includes("/comments"));
170+
const patchCall = calls.find((call) => call.method === "PATCH");
171+
expect(commentCall?.url).toContain("/issues/6145/comments");
172+
expect(commentCall?.body).toMatchObject({ body: expect.stringContaining("caught up") });
173+
expect(patchCall?.url).toContain("/issues/6145");
174+
expect(patchCall?.body).toEqual({ state: "closed", state_reason: "completed" });
175+
});
176+
177+
it("does nothing when no open watch issue exists", async () => {
178+
process.env.GITHUB_REPOSITORY = "acme/widgets";
179+
process.env.GITHUB_TOKEN = "test-token";
180+
const fetchMock = vi.fn(async () => new Response("[]", { status: 200 }));
181+
vi.stubGlobal("fetch", fetchMock);
182+
183+
await closeResolvedIssueIfPresent(resolvedReport);
184+
185+
expect(fetchMock).toHaveBeenCalledTimes(1);
186+
});
187+
188+
it("ignores an open issue authored by someone other than github-actions[bot]", async () => {
189+
process.env.GITHUB_REPOSITORY = "acme/widgets";
190+
process.env.GITHUB_TOKEN = "test-token";
191+
// findExistingIssue pages until an empty page ends the search -- page 1 returns a non-matching
192+
// issue, page 2 must come back empty or the (real) pagination loop keeps requesting pages 3..10.
193+
const fetchMock = vi
194+
.fn()
195+
.mockResolvedValueOnce(
196+
new Response(
197+
JSON.stringify([
198+
{ number: 9, title: "MCP release due: 4.0.0", body: "<!-- loopover:mcp-release-due -->", user: { login: "someone-else" } },
199+
]),
200+
{ status: 200 },
201+
),
202+
)
203+
.mockResolvedValue(new Response("[]", { status: 200 }));
204+
vi.stubGlobal("fetch", fetchMock);
205+
206+
await closeResolvedIssueIfPresent(resolvedReport);
207+
208+
expect(fetchMock).toHaveBeenCalledTimes(2);
209+
});
210+
});

0 commit comments

Comments
 (0)