Skip to content

Commit 1b40461

Browse files
anandgupta42claude
andauthored
feat: restrict upstream merge to published GitHub releases only (#150)
* 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> * fix: handle paginated `gh api` output with `--slurp` in `fetchReleases` `gh api --paginate` outputs concatenated JSON arrays (`[...][...]`) when results span multiple pages, which `JSON.parse` cannot handle. Use `--slurp` to combine all pages into a single array, then `flatten` the result before filtering. Also reorders the jq pipeline to filter before slicing, so `--limit N` returns N stable releases rather than N total (which could include pre-releases that get filtered out). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace `--slurp` with `jq -s` pipe for `gh api` pagination `gh api` does not support `--slurp` — it's a jq-only flag. Instead, use `--jq '.[]'` to unpack each page's array into individual JSON objects, then pipe to external `jq -s` to slurp them into a single array for filtering and slicing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pass CLI `--limit`/`--all` to `fetchReleases` The `limit` from CLI args was not passed to `fetchReleases`, so it always defaulted to 100 internally. Now `list-versions.ts` passes the limit through, and `--all` passes `undefined` to skip the jq slice. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c10b571 commit 1b40461

File tree

5 files changed

+677
-51
lines changed

5 files changed

+677
-51
lines changed
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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+
test("pipes paginated output to external jq for slurping", async () => {
148+
mockExecOutput = "[]"
149+
150+
await fetchReleases("anomalyco/opencode")
151+
// Uses --jq '.[]' to unpack pages, then pipes to jq -s for slurping
152+
expect(lastExecCmd).toContain("--jq '.[]'")
153+
expect(lastExecCmd).toContain("| jq -s")
154+
})
155+
156+
test("filters before slicing (filter then limit)", async () => {
157+
mockExecOutput = "[]"
158+
159+
await fetchReleases("anomalyco/opencode", { limit: 10 })
160+
expect(lastExecCmd).toContain("[.[] | select(")
161+
expect(lastExecCmd).toMatch(/select\(.*\)\] \| \.\[0:10\]/)
162+
})
163+
})
164+
165+
// ---------------------------------------------------------------------------
166+
// getRelease
167+
// ---------------------------------------------------------------------------
168+
169+
describe("getRelease()", () => {
170+
beforeEach(() => {
171+
mockExecOutput = null
172+
mockExecShouldThrow = false
173+
lastExecCmd = null
174+
})
175+
176+
test("returns release for a valid published tag", async () => {
177+
mockExecOutput = JSON.stringify(MOCK_RELEASES[0])
178+
179+
const release = await getRelease("anomalyco/opencode", "v1.2.26")
180+
expect(release).not.toBeNull()
181+
expect(release!.tag_name).toBe("v1.2.26")
182+
expect(release!.draft).toBe(false)
183+
})
184+
185+
test("returns null for a draft release", async () => {
186+
mockExecOutput = JSON.stringify(MOCK_DRAFT_RELEASE)
187+
188+
const release = await getRelease("anomalyco/opencode", "v1.3.0")
189+
expect(release).toBeNull()
190+
})
191+
192+
test("returns null when tag does not exist", async () => {
193+
mockExecShouldThrow = true
194+
195+
const release = await getRelease("anomalyco/opencode", "v99.99.99")
196+
expect(release).toBeNull()
197+
})
198+
199+
test("returns null for empty response", async () => {
200+
mockExecOutput = ""
201+
202+
const release = await getRelease("anomalyco/opencode", "v1.2.26")
203+
expect(release).toBeNull()
204+
})
205+
206+
test("queries the correct tag endpoint", async () => {
207+
mockExecOutput = JSON.stringify(MOCK_RELEASES[0])
208+
209+
await getRelease("anomalyco/opencode", "v1.2.26")
210+
expect(lastExecCmd).toContain("releases/tags/v1.2.26")
211+
})
212+
})
213+
214+
// ---------------------------------------------------------------------------
215+
// getReleaseTags
216+
// ---------------------------------------------------------------------------
217+
218+
describe("getReleaseTags()", () => {
219+
beforeEach(() => {
220+
mockExecOutput = null
221+
mockExecShouldThrow = false
222+
})
223+
224+
test("returns only tag names from releases", async () => {
225+
const stableOnly = MOCK_RELEASES.filter((r) => !r.prerelease && !r.draft)
226+
mockExecOutput = JSON.stringify(stableOnly)
227+
228+
const tags = await getReleaseTags("anomalyco/opencode")
229+
expect(tags).toEqual(["v1.2.26", "v1.2.25", "v1.2.24"])
230+
})
231+
232+
test("includes pre-release tags when requested", async () => {
233+
const nonDraft = MOCK_RELEASES.filter((r) => !r.draft)
234+
mockExecOutput = JSON.stringify(nonDraft)
235+
236+
const tags = await getReleaseTags("anomalyco/opencode", {
237+
includePrerelease: true,
238+
})
239+
expect(tags).toContain("v1.2.24-beta.1")
240+
})
241+
})
242+
243+
// ---------------------------------------------------------------------------
244+
// validateRelease
245+
// ---------------------------------------------------------------------------
246+
247+
describe("validateRelease()", () => {
248+
beforeEach(() => {
249+
mockExecOutput = null
250+
mockExecShouldThrow = false
251+
})
252+
253+
test("valid: true for a published stable release", async () => {
254+
mockExecOutput = JSON.stringify(MOCK_RELEASES[0])
255+
256+
const result = await validateRelease("anomalyco/opencode", "v1.2.26")
257+
expect(result.valid).toBe(true)
258+
expect(result.release).toBeDefined()
259+
expect(result.release!.tag_name).toBe("v1.2.26")
260+
})
261+
262+
test("valid: false for a non-existent tag", async () => {
263+
mockExecShouldThrow = true
264+
265+
const result = await validateRelease("anomalyco/opencode", "v99.99.99")
266+
expect(result.valid).toBe(false)
267+
expect(result.reason).toContain("not a published GitHub release")
268+
})
269+
270+
test("valid: false for a pre-release", async () => {
271+
mockExecOutput = JSON.stringify(MOCK_RELEASES[2]) // beta
272+
273+
const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1")
274+
expect(result.valid).toBe(false)
275+
expect(result.reason).toContain("pre-release")
276+
})
277+
278+
test("valid: false for a draft release", async () => {
279+
mockExecOutput = JSON.stringify(MOCK_DRAFT_RELEASE)
280+
281+
// getRelease returns null for drafts
282+
const result = await validateRelease("anomalyco/opencode", "v1.3.0")
283+
expect(result.valid).toBe(false)
284+
})
285+
286+
test("reason includes --include-prerelease hint for pre-releases", async () => {
287+
mockExecOutput = JSON.stringify(MOCK_RELEASES[2])
288+
289+
const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1")
290+
expect(result.reason).toContain("--include-prerelease")
291+
})
292+
293+
test("reason mentions the repo name for non-existent tags", async () => {
294+
mockExecShouldThrow = true
295+
296+
const result = await validateRelease("anomalyco/opencode", "vscode-v0.0.5")
297+
expect(result.reason).toContain("anomalyco/opencode")
298+
})
299+
300+
test("valid: true for a pre-release when includePrerelease is true", async () => {
301+
mockExecOutput = JSON.stringify(MOCK_RELEASES[2]) // beta
302+
303+
const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1", {
304+
includePrerelease: true,
305+
})
306+
expect(result.valid).toBe(true)
307+
expect(result.release).toBeDefined()
308+
expect(result.release!.prerelease).toBe(true)
309+
})
310+
311+
test("valid: false for a pre-release when includePrerelease is false", async () => {
312+
mockExecOutput = JSON.stringify(MOCK_RELEASES[2])
313+
314+
const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1", {
315+
includePrerelease: false,
316+
})
317+
expect(result.valid).toBe(false)
318+
expect(result.reason).toContain("pre-release")
319+
})
320+
})

0 commit comments

Comments
 (0)