Skip to content

Commit e32c2b1

Browse files
anandgupta42claude
andcommitted
feat: restrict upstream merge to published GitHub releases only
- Add `script/upstream/utils/github.ts` — GitHub Releases API utilities that fetch, validate, and list published releases via `gh` CLI - Remove `--commit` flag from merge.ts — arbitrary commit merges no longer allowed - Add `--include-prerelease` flag for both `merge.ts` and `list-versions.ts` - Replace git tag listing with GitHub Releases API in `list-versions.ts` - Validate versions against GitHub releases before merging (not just git tags) - Fix: `validateRelease()` accepts `includePrerelease` option to properly support the `--include-prerelease` flag (caught by multi-model code review) - Fix: Remove duplicate `getRelease()` call (dead code) in `validateRelease()` - Add 46 tests covering GitHub API utilities, release validation, and structural enforcement of the release-only policy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3721c88 commit e32c2b1

File tree

5 files changed

+648
-51
lines changed

5 files changed

+648
-51
lines changed
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { describe, expect, test, mock, beforeEach } from "bun:test"
2+
3+
// ---------------------------------------------------------------------------
4+
// Mock execSync to avoid real GitHub API calls
5+
// ---------------------------------------------------------------------------
6+
7+
let mockExecOutput: string | null = null
8+
let mockExecShouldThrow = false
9+
let lastExecCmd: string | null = null
10+
11+
// Spread the real child_process module so `spawn`, `exec`, etc. still work,
12+
// and only override `execSync` for our tests.
13+
import * as realChildProcess from "child_process"
14+
15+
mock.module("child_process", () => ({
16+
...realChildProcess,
17+
execSync: (cmd: string, _opts?: any) => {
18+
lastExecCmd = cmd
19+
if (mockExecShouldThrow) throw new Error("exec failed")
20+
return mockExecOutput ?? ""
21+
},
22+
}))
23+
24+
// Import after mocking
25+
const { fetchReleases, getRelease, getReleaseTags, validateRelease } = await import(
26+
"../../../../script/upstream/utils/github"
27+
)
28+
29+
// ---------------------------------------------------------------------------
30+
// Test data
31+
// ---------------------------------------------------------------------------
32+
33+
const MOCK_RELEASES = [
34+
{
35+
tag_name: "v1.2.26",
36+
name: "v1.2.26",
37+
prerelease: false,
38+
draft: false,
39+
published_at: "2026-03-13T16:33:18Z",
40+
html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.2.26",
41+
},
42+
{
43+
tag_name: "v1.2.25",
44+
name: "v1.2.25",
45+
prerelease: false,
46+
draft: false,
47+
published_at: "2026-03-12T23:34:33Z",
48+
html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.2.25",
49+
},
50+
{
51+
tag_name: "v1.2.24-beta.1",
52+
name: "v1.2.24-beta.1",
53+
prerelease: true,
54+
draft: false,
55+
published_at: "2026-03-08T10:00:00Z",
56+
html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.2.24-beta.1",
57+
},
58+
{
59+
tag_name: "v1.2.24",
60+
name: "v1.2.24",
61+
prerelease: false,
62+
draft: false,
63+
published_at: "2026-03-09T16:10:00Z",
64+
html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.2.24",
65+
},
66+
]
67+
68+
const MOCK_DRAFT_RELEASE = {
69+
tag_name: "v1.3.0",
70+
name: "v1.3.0",
71+
prerelease: false,
72+
draft: true,
73+
published_at: "2026-03-15T00:00:00Z",
74+
html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.3.0",
75+
}
76+
77+
// ---------------------------------------------------------------------------
78+
// fetchReleases
79+
// ---------------------------------------------------------------------------
80+
81+
describe("fetchReleases()", () => {
82+
beforeEach(() => {
83+
mockExecOutput = null
84+
mockExecShouldThrow = false
85+
lastExecCmd = null
86+
})
87+
88+
test("returns stable releases, excluding drafts and pre-releases", async () => {
89+
const stableOnly = MOCK_RELEASES.filter((r) => !r.prerelease && !r.draft)
90+
mockExecOutput = JSON.stringify(stableOnly)
91+
92+
const releases = await fetchReleases("anomalyco/opencode")
93+
expect(releases).toHaveLength(3)
94+
expect(releases.every((r) => !r.prerelease && !r.draft)).toBe(true)
95+
})
96+
97+
test("includes pre-releases when includePrerelease is true", async () => {
98+
const nonDraft = MOCK_RELEASES.filter((r) => !r.draft)
99+
mockExecOutput = JSON.stringify(nonDraft)
100+
101+
const releases = await fetchReleases("anomalyco/opencode", {
102+
includePrerelease: true,
103+
})
104+
expect(releases).toHaveLength(4)
105+
expect(releases.some((r) => r.prerelease)).toBe(true)
106+
})
107+
108+
test("excludes draft releases even with includePrerelease", async () => {
109+
const allWithDraft = [...MOCK_RELEASES, MOCK_DRAFT_RELEASE].filter((r) => !r.draft)
110+
mockExecOutput = JSON.stringify(allWithDraft)
111+
112+
const releases = await fetchReleases("anomalyco/opencode", {
113+
includePrerelease: true,
114+
})
115+
expect(releases.every((r) => !r.draft)).toBe(true)
116+
})
117+
118+
test("returns empty array when no releases exist", async () => {
119+
mockExecOutput = ""
120+
121+
const releases = await fetchReleases("anomalyco/opencode")
122+
expect(releases).toEqual([])
123+
})
124+
125+
test("throws on API failure", async () => {
126+
mockExecShouldThrow = true
127+
128+
expect(fetchReleases("anomalyco/opencode")).rejects.toThrow(
129+
"Failed to fetch releases",
130+
)
131+
})
132+
133+
test("calls gh API with correct repo", async () => {
134+
mockExecOutput = "[]"
135+
136+
await fetchReleases("anomalyco/opencode")
137+
expect(lastExecCmd).toContain("repos/anomalyco/opencode/releases")
138+
})
139+
140+
test("respects limit parameter", async () => {
141+
mockExecOutput = "[]"
142+
143+
await fetchReleases("anomalyco/opencode", { limit: 5 })
144+
expect(lastExecCmd).toContain(".[0:5]")
145+
})
146+
})
147+
148+
// ---------------------------------------------------------------------------
149+
// getRelease
150+
// ---------------------------------------------------------------------------
151+
152+
describe("getRelease()", () => {
153+
beforeEach(() => {
154+
mockExecOutput = null
155+
mockExecShouldThrow = false
156+
lastExecCmd = null
157+
})
158+
159+
test("returns release for a valid published tag", async () => {
160+
mockExecOutput = JSON.stringify(MOCK_RELEASES[0])
161+
162+
const release = await getRelease("anomalyco/opencode", "v1.2.26")
163+
expect(release).not.toBeNull()
164+
expect(release!.tag_name).toBe("v1.2.26")
165+
expect(release!.draft).toBe(false)
166+
})
167+
168+
test("returns null for a draft release", async () => {
169+
mockExecOutput = JSON.stringify(MOCK_DRAFT_RELEASE)
170+
171+
const release = await getRelease("anomalyco/opencode", "v1.3.0")
172+
expect(release).toBeNull()
173+
})
174+
175+
test("returns null when tag does not exist", async () => {
176+
mockExecShouldThrow = true
177+
178+
const release = await getRelease("anomalyco/opencode", "v99.99.99")
179+
expect(release).toBeNull()
180+
})
181+
182+
test("returns null for empty response", async () => {
183+
mockExecOutput = ""
184+
185+
const release = await getRelease("anomalyco/opencode", "v1.2.26")
186+
expect(release).toBeNull()
187+
})
188+
189+
test("queries the correct tag endpoint", async () => {
190+
mockExecOutput = JSON.stringify(MOCK_RELEASES[0])
191+
192+
await getRelease("anomalyco/opencode", "v1.2.26")
193+
expect(lastExecCmd).toContain("releases/tags/v1.2.26")
194+
})
195+
})
196+
197+
// ---------------------------------------------------------------------------
198+
// getReleaseTags
199+
// ---------------------------------------------------------------------------
200+
201+
describe("getReleaseTags()", () => {
202+
beforeEach(() => {
203+
mockExecOutput = null
204+
mockExecShouldThrow = false
205+
})
206+
207+
test("returns only tag names from releases", async () => {
208+
const stableOnly = MOCK_RELEASES.filter((r) => !r.prerelease && !r.draft)
209+
mockExecOutput = JSON.stringify(stableOnly)
210+
211+
const tags = await getReleaseTags("anomalyco/opencode")
212+
expect(tags).toEqual(["v1.2.26", "v1.2.25", "v1.2.24"])
213+
})
214+
215+
test("includes pre-release tags when requested", async () => {
216+
const nonDraft = MOCK_RELEASES.filter((r) => !r.draft)
217+
mockExecOutput = JSON.stringify(nonDraft)
218+
219+
const tags = await getReleaseTags("anomalyco/opencode", {
220+
includePrerelease: true,
221+
})
222+
expect(tags).toContain("v1.2.24-beta.1")
223+
})
224+
})
225+
226+
// ---------------------------------------------------------------------------
227+
// validateRelease
228+
// ---------------------------------------------------------------------------
229+
230+
describe("validateRelease()", () => {
231+
beforeEach(() => {
232+
mockExecOutput = null
233+
mockExecShouldThrow = false
234+
})
235+
236+
test("valid: true for a published stable release", async () => {
237+
mockExecOutput = JSON.stringify(MOCK_RELEASES[0])
238+
239+
const result = await validateRelease("anomalyco/opencode", "v1.2.26")
240+
expect(result.valid).toBe(true)
241+
expect(result.release).toBeDefined()
242+
expect(result.release!.tag_name).toBe("v1.2.26")
243+
})
244+
245+
test("valid: false for a non-existent tag", async () => {
246+
mockExecShouldThrow = true
247+
248+
const result = await validateRelease("anomalyco/opencode", "v99.99.99")
249+
expect(result.valid).toBe(false)
250+
expect(result.reason).toContain("not a published GitHub release")
251+
})
252+
253+
test("valid: false for a pre-release", async () => {
254+
mockExecOutput = JSON.stringify(MOCK_RELEASES[2]) // beta
255+
256+
const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1")
257+
expect(result.valid).toBe(false)
258+
expect(result.reason).toContain("pre-release")
259+
})
260+
261+
test("valid: false for a draft release", async () => {
262+
mockExecOutput = JSON.stringify(MOCK_DRAFT_RELEASE)
263+
264+
// getRelease returns null for drafts
265+
const result = await validateRelease("anomalyco/opencode", "v1.3.0")
266+
expect(result.valid).toBe(false)
267+
})
268+
269+
test("reason includes --include-prerelease hint for pre-releases", async () => {
270+
mockExecOutput = JSON.stringify(MOCK_RELEASES[2])
271+
272+
const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1")
273+
expect(result.reason).toContain("--include-prerelease")
274+
})
275+
276+
test("reason mentions the repo name for non-existent tags", async () => {
277+
mockExecShouldThrow = true
278+
279+
const result = await validateRelease("anomalyco/opencode", "vscode-v0.0.5")
280+
expect(result.reason).toContain("anomalyco/opencode")
281+
})
282+
283+
test("valid: true for a pre-release when includePrerelease is true", async () => {
284+
mockExecOutput = JSON.stringify(MOCK_RELEASES[2]) // beta
285+
286+
const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1", {
287+
includePrerelease: true,
288+
})
289+
expect(result.valid).toBe(true)
290+
expect(result.release).toBeDefined()
291+
expect(result.release!.prerelease).toBe(true)
292+
})
293+
294+
test("valid: false for a pre-release when includePrerelease is false", async () => {
295+
mockExecOutput = JSON.stringify(MOCK_RELEASES[2])
296+
297+
const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1", {
298+
includePrerelease: false,
299+
})
300+
expect(result.valid).toBe(false)
301+
expect(result.reason).toContain("pre-release")
302+
})
303+
})

0 commit comments

Comments
 (0)