diff --git a/packages/opencode/test/upstream/github.test.ts b/packages/opencode/test/upstream/github.test.ts new file mode 100644 index 0000000000..1452eae4a1 --- /dev/null +++ b/packages/opencode/test/upstream/github.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test" + +// --------------------------------------------------------------------------- +// Mock execSync to avoid real GitHub API calls +// --------------------------------------------------------------------------- + +let mockExecOutput: string | null = null +let mockExecShouldThrow = false +let lastExecCmd: string | null = null + +// Spread the real child_process module so `spawn`, `exec`, etc. still work, +// and only override `execSync` for our tests. +import * as realChildProcess from "child_process" + +mock.module("child_process", () => ({ + ...realChildProcess, + execSync: (cmd: string, _opts?: any) => { + lastExecCmd = cmd + if (mockExecShouldThrow) throw new Error("exec failed") + return mockExecOutput ?? "" + }, +})) + +// Import after mocking +const { fetchReleases, getRelease, getReleaseTags, validateRelease } = await import( + "../../../../script/upstream/utils/github" +) + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const MOCK_RELEASES = [ + { + tag_name: "v1.2.26", + name: "v1.2.26", + prerelease: false, + draft: false, + published_at: "2026-03-13T16:33:18Z", + html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.2.26", + }, + { + tag_name: "v1.2.25", + name: "v1.2.25", + prerelease: false, + draft: false, + published_at: "2026-03-12T23:34:33Z", + html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.2.25", + }, + { + tag_name: "v1.2.24-beta.1", + name: "v1.2.24-beta.1", + prerelease: true, + draft: false, + published_at: "2026-03-08T10:00:00Z", + html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.2.24-beta.1", + }, + { + tag_name: "v1.2.24", + name: "v1.2.24", + prerelease: false, + draft: false, + published_at: "2026-03-09T16:10:00Z", + html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.2.24", + }, +] + +const MOCK_DRAFT_RELEASE = { + tag_name: "v1.3.0", + name: "v1.3.0", + prerelease: false, + draft: true, + published_at: "2026-03-15T00:00:00Z", + html_url: "https://github.com/anomalyco/opencode/releases/tag/v1.3.0", +} + +// --------------------------------------------------------------------------- +// fetchReleases +// --------------------------------------------------------------------------- + +describe("fetchReleases()", () => { + beforeEach(() => { + mockExecOutput = null + mockExecShouldThrow = false + lastExecCmd = null + }) + + test("returns stable releases, excluding drafts and pre-releases", async () => { + const stableOnly = MOCK_RELEASES.filter((r) => !r.prerelease && !r.draft) + mockExecOutput = JSON.stringify(stableOnly) + + const releases = await fetchReleases("anomalyco/opencode") + expect(releases).toHaveLength(3) + expect(releases.every((r) => !r.prerelease && !r.draft)).toBe(true) + }) + + test("includes pre-releases when includePrerelease is true", async () => { + const nonDraft = MOCK_RELEASES.filter((r) => !r.draft) + mockExecOutput = JSON.stringify(nonDraft) + + const releases = await fetchReleases("anomalyco/opencode", { + includePrerelease: true, + }) + expect(releases).toHaveLength(4) + expect(releases.some((r) => r.prerelease)).toBe(true) + }) + + test("excludes draft releases even with includePrerelease", async () => { + const allWithDraft = [...MOCK_RELEASES, MOCK_DRAFT_RELEASE].filter((r) => !r.draft) + mockExecOutput = JSON.stringify(allWithDraft) + + const releases = await fetchReleases("anomalyco/opencode", { + includePrerelease: true, + }) + expect(releases.every((r) => !r.draft)).toBe(true) + }) + + test("returns empty array when no releases exist", async () => { + mockExecOutput = "" + + const releases = await fetchReleases("anomalyco/opencode") + expect(releases).toEqual([]) + }) + + test("throws on API failure", async () => { + mockExecShouldThrow = true + + expect(fetchReleases("anomalyco/opencode")).rejects.toThrow( + "Failed to fetch releases", + ) + }) + + test("calls gh API with correct repo", async () => { + mockExecOutput = "[]" + + await fetchReleases("anomalyco/opencode") + expect(lastExecCmd).toContain("repos/anomalyco/opencode/releases") + }) + + test("respects limit parameter", async () => { + mockExecOutput = "[]" + + await fetchReleases("anomalyco/opencode", { limit: 5 }) + expect(lastExecCmd).toContain(".[0:5]") + }) + + test("pipes paginated output to external jq for slurping", async () => { + mockExecOutput = "[]" + + await fetchReleases("anomalyco/opencode") + // Uses --jq '.[]' to unpack pages, then pipes to jq -s for slurping + expect(lastExecCmd).toContain("--jq '.[]'") + expect(lastExecCmd).toContain("| jq -s") + }) + + test("filters before slicing (filter then limit)", async () => { + mockExecOutput = "[]" + + await fetchReleases("anomalyco/opencode", { limit: 10 }) + expect(lastExecCmd).toContain("[.[] | select(") + expect(lastExecCmd).toMatch(/select\(.*\)\] \| \.\[0:10\]/) + }) +}) + +// --------------------------------------------------------------------------- +// getRelease +// --------------------------------------------------------------------------- + +describe("getRelease()", () => { + beforeEach(() => { + mockExecOutput = null + mockExecShouldThrow = false + lastExecCmd = null + }) + + test("returns release for a valid published tag", async () => { + mockExecOutput = JSON.stringify(MOCK_RELEASES[0]) + + const release = await getRelease("anomalyco/opencode", "v1.2.26") + expect(release).not.toBeNull() + expect(release!.tag_name).toBe("v1.2.26") + expect(release!.draft).toBe(false) + }) + + test("returns null for a draft release", async () => { + mockExecOutput = JSON.stringify(MOCK_DRAFT_RELEASE) + + const release = await getRelease("anomalyco/opencode", "v1.3.0") + expect(release).toBeNull() + }) + + test("returns null when tag does not exist", async () => { + mockExecShouldThrow = true + + const release = await getRelease("anomalyco/opencode", "v99.99.99") + expect(release).toBeNull() + }) + + test("returns null for empty response", async () => { + mockExecOutput = "" + + const release = await getRelease("anomalyco/opencode", "v1.2.26") + expect(release).toBeNull() + }) + + test("queries the correct tag endpoint", async () => { + mockExecOutput = JSON.stringify(MOCK_RELEASES[0]) + + await getRelease("anomalyco/opencode", "v1.2.26") + expect(lastExecCmd).toContain("releases/tags/v1.2.26") + }) +}) + +// --------------------------------------------------------------------------- +// getReleaseTags +// --------------------------------------------------------------------------- + +describe("getReleaseTags()", () => { + beforeEach(() => { + mockExecOutput = null + mockExecShouldThrow = false + }) + + test("returns only tag names from releases", async () => { + const stableOnly = MOCK_RELEASES.filter((r) => !r.prerelease && !r.draft) + mockExecOutput = JSON.stringify(stableOnly) + + const tags = await getReleaseTags("anomalyco/opencode") + expect(tags).toEqual(["v1.2.26", "v1.2.25", "v1.2.24"]) + }) + + test("includes pre-release tags when requested", async () => { + const nonDraft = MOCK_RELEASES.filter((r) => !r.draft) + mockExecOutput = JSON.stringify(nonDraft) + + const tags = await getReleaseTags("anomalyco/opencode", { + includePrerelease: true, + }) + expect(tags).toContain("v1.2.24-beta.1") + }) +}) + +// --------------------------------------------------------------------------- +// validateRelease +// --------------------------------------------------------------------------- + +describe("validateRelease()", () => { + beforeEach(() => { + mockExecOutput = null + mockExecShouldThrow = false + }) + + test("valid: true for a published stable release", async () => { + mockExecOutput = JSON.stringify(MOCK_RELEASES[0]) + + const result = await validateRelease("anomalyco/opencode", "v1.2.26") + expect(result.valid).toBe(true) + expect(result.release).toBeDefined() + expect(result.release!.tag_name).toBe("v1.2.26") + }) + + test("valid: false for a non-existent tag", async () => { + mockExecShouldThrow = true + + const result = await validateRelease("anomalyco/opencode", "v99.99.99") + expect(result.valid).toBe(false) + expect(result.reason).toContain("not a published GitHub release") + }) + + test("valid: false for a pre-release", async () => { + mockExecOutput = JSON.stringify(MOCK_RELEASES[2]) // beta + + const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1") + expect(result.valid).toBe(false) + expect(result.reason).toContain("pre-release") + }) + + test("valid: false for a draft release", async () => { + mockExecOutput = JSON.stringify(MOCK_DRAFT_RELEASE) + + // getRelease returns null for drafts + const result = await validateRelease("anomalyco/opencode", "v1.3.0") + expect(result.valid).toBe(false) + }) + + test("reason includes --include-prerelease hint for pre-releases", async () => { + mockExecOutput = JSON.stringify(MOCK_RELEASES[2]) + + const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1") + expect(result.reason).toContain("--include-prerelease") + }) + + test("reason mentions the repo name for non-existent tags", async () => { + mockExecShouldThrow = true + + const result = await validateRelease("anomalyco/opencode", "vscode-v0.0.5") + expect(result.reason).toContain("anomalyco/opencode") + }) + + test("valid: true for a pre-release when includePrerelease is true", async () => { + mockExecOutput = JSON.stringify(MOCK_RELEASES[2]) // beta + + const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1", { + includePrerelease: true, + }) + expect(result.valid).toBe(true) + expect(result.release).toBeDefined() + expect(result.release!.prerelease).toBe(true) + }) + + test("valid: false for a pre-release when includePrerelease is false", async () => { + mockExecOutput = JSON.stringify(MOCK_RELEASES[2]) + + const result = await validateRelease("anomalyco/opencode", "v1.2.24-beta.1", { + includePrerelease: false, + }) + expect(result.valid).toBe(false) + expect(result.reason).toContain("pre-release") + }) +}) diff --git a/packages/opencode/test/upstream/release-only-merge.test.ts b/packages/opencode/test/upstream/release-only-merge.test.ts new file mode 100644 index 0000000000..3f2e6c4a1c --- /dev/null +++ b/packages/opencode/test/upstream/release-only-merge.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs" +import path from "path" + +// --------------------------------------------------------------------------- +// These tests verify the upstream merge tooling is configured to only +// accept published GitHub releases, not arbitrary commits or tags. +// --------------------------------------------------------------------------- + +const SCRIPT_DIR = path.resolve(__dirname, "..", "..", "..", "..", "script", "upstream") + +describe("merge.ts release-only enforcement", () => { + const mergeScript = fs.readFileSync( + path.join(SCRIPT_DIR, "merge.ts"), + "utf-8", + ) + + test("does not accept --commit flag", () => { + // The --commit option should have been removed + expect(mergeScript).not.toContain('"commit"') + expect(mergeScript).not.toContain("'commit'") + }) + + test("imports validateRelease from github utils", () => { + expect(mergeScript).toContain("validateRelease") + expect(mergeScript).toContain("./utils/github") + }) + + test("validates version against GitHub releases", () => { + expect(mergeScript).toContain("validateRelease(") + expect(mergeScript).toContain("published release") + }) + + test("supports --include-prerelease flag", () => { + expect(mergeScript).toContain("include-prerelease") + }) + + test("does not reference commitRef or commit SHA merging", () => { + // No references to merging arbitrary commit SHAs + expect(mergeScript).not.toContain("commitRef") + expect(mergeScript).not.toContain("--commit") + }) + + test("shows recent releases on validation failure", () => { + expect(mergeScript).toContain("getReleaseTags") + expect(mergeScript).toContain("Recent releases") + }) + + test("displays published date on validation success", () => { + expect(mergeScript).toContain("published release") + expect(mergeScript).toContain("published_at") + }) + + test("help text mentions only releases, not commits", () => { + // Extract help text (between printUsage function) + const helpMatch = mergeScript.match( + /function printUsage[\s\S]*?^}/m, + ) + expect(helpMatch).not.toBeNull() + const helpText = helpMatch![0] + expect(helpText).not.toContain("--commit") + expect(helpText).not.toContain("Merge a specific commit") + expect(helpText).toContain("Only published GitHub releases") + }) +}) + +describe("list-versions.ts release-based listing", () => { + const listScript = fs.readFileSync( + path.join(SCRIPT_DIR, "list-versions.ts"), + "utf-8", + ) + + test("imports fetchReleases from github utils", () => { + expect(listScript).toContain("fetchReleases") + expect(listScript).toContain("./utils/github") + }) + + test("fetches releases from GitHub API, not just git tags", () => { + expect(listScript).toContain("fetchReleases(") + expect(listScript).toContain("published releases") + }) + + test("supports --include-prerelease flag", () => { + expect(listScript).toContain("include-prerelease") + }) + + test("header says Releases not Versions", () => { + expect(listScript).toContain("Upstream OpenCode Releases") + }) + + test("help text mentions only releases", () => { + expect(listScript).toContain("Only published GitHub releases are shown") + }) +}) + +describe("utils/github.ts module structure", () => { + const githubModule = fs.readFileSync( + path.join(SCRIPT_DIR, "utils", "github.ts"), + "utf-8", + ) + + test("exports fetchReleases function", () => { + expect(githubModule).toContain("export async function fetchReleases") + }) + + test("exports getRelease function", () => { + expect(githubModule).toContain("export async function getRelease") + }) + + test("exports getReleaseTags function", () => { + expect(githubModule).toContain("export async function getReleaseTags") + }) + + test("exports validateRelease function", () => { + expect(githubModule).toContain("export async function validateRelease") + }) + + test("exports GitHubRelease interface", () => { + expect(githubModule).toContain("export interface GitHubRelease") + }) + + test("filters out draft releases by default", () => { + expect(githubModule).toContain("draft == false") + }) + + test("filters out pre-releases by default", () => { + expect(githubModule).toContain("prerelease == false") + }) + + test("uses gh CLI for API calls", () => { + expect(githubModule).toContain("gh api") + }) + + test("pipes to external jq for paginated output handling", () => { + expect(githubModule).toContain("jq -s") + expect(githubModule).toContain("--jq '.[]'") + }) + + test("getRelease returns null for draft releases", () => { + expect(githubModule).toContain("if (release.draft) return null") + }) +}) + +describe("config.ts still references upstream repo correctly", () => { + const configModule = fs.readFileSync( + path.join(SCRIPT_DIR, "utils", "config.ts"), + "utf-8", + ) + + test("upstreamRepo points to anomalyco/opencode", () => { + expect(configModule).toContain('"anomalyco/opencode"') + }) + + test("originRepo points to AltimateAI/altimate-code", () => { + expect(configModule).toContain('"AltimateAI/altimate-code"') + }) +}) diff --git a/script/upstream/list-versions.ts b/script/upstream/list-versions.ts index 06e4519909..dbfc27fffd 100644 --- a/script/upstream/list-versions.ts +++ b/script/upstream/list-versions.ts @@ -2,7 +2,9 @@ /** * List Upstream Versions * - * Shows available upstream OpenCode versions with merge status indicators. + * Shows available upstream OpenCode releases (from GitHub Releases API) + * with merge status indicators. Only published releases are shown — + * arbitrary tags and commits are excluded. * * Usage: * bun run script/upstream/list-versions.ts @@ -16,6 +18,7 @@ import { $ } from "bun" import * as git from "./utils/git" import * as logger from "./utils/logger" import { loadConfig, repoRoot } from "./utils/config" +import { fetchReleases, type GitHubRelease } from "./utils/github" // --------------------------------------------------------------------------- // CLI @@ -25,6 +28,7 @@ const { values: args } = parseArgs({ options: { limit: { type: "string", default: "30" }, all: { type: "boolean", default: false }, + "include-prerelease": { type: "boolean", default: false }, json: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, @@ -144,10 +148,15 @@ function printUsage(): void { bun run script/upstream/list-versions.ts --all --json ${bold("OPTIONS")} - --limit Number of versions to show (default: 30) - --all Show all versions (no limit) - --json Output as JSON - --help, -h Show this help message + --limit Number of versions to show (default: 30) + --all Show all versions (no limit) + --include-prerelease Include pre-release versions + --json Output as JSON + --help, -h Show this help message + + ${bold("NOTE")} + Only published GitHub releases are shown. Arbitrary tags and + commits are not listed. This ensures you only merge stable releases. ${bold("LEGEND")} ${GREEN}merged${RESET} Already merged into current branch @@ -166,7 +175,7 @@ async function main(): Promise { const root = repoRoot() const limit = args.all ? Infinity : parseInt(args.limit || "30", 10) - // Ensure upstream remote exists + // Ensure upstream remote exists (for merge-base checks) const hasUpstream = await git.hasRemote(config.upstreamRemote) if (!hasUpstream) { logger.info(`Adding upstream remote: ${config.upstreamRepo}`) @@ -176,26 +185,30 @@ async function main(): Promise { ) } - // Fetch upstream tags + // Fetch upstream tags so merge-base checks work logger.info(`Fetching tags from ${config.upstreamRemote}...`) await git.fetchRemote(config.upstreamRemote) - // Get all tags - const allTags = await git.getTags(config.upstreamRemote) + // Get published releases from GitHub API (not raw git tags) + logger.info(`Fetching releases from GitHub (${config.upstreamRepo})...`) + const releases = await fetchReleases(config.upstreamRepo, { + includePrerelease: Boolean(args["include-prerelease"]), + limit: limit === Infinity ? undefined : limit, + }) - // Filter to version tags (v*.*.* or *.*.*) - const versionTags = allTags - .filter((tag) => /^v?\d+\.\d+\.\d+/.test(tag)) + // Extract tags and sort by version + const versionTags = releases + .map((r) => r.tag_name) .sort(compareVersions) if (versionTags.length === 0) { - logger.warn("No version tags found on upstream") + logger.warn("No published releases found on upstream") process.exit(0) } const tagsToShow = versionTags.slice(0, Math.min(limit, versionTags.length)) - logger.info(`Found ${versionTags.length} version tags, showing ${tagsToShow.length}`) + logger.info(`Found ${versionTags.length} published releases, showing ${tagsToShow.length}`) console.log() // Gather info for each tag (batch lookups for performance) @@ -256,7 +269,7 @@ async function main(): Promise { // Header const line = "═".repeat(60) console.log(`${CYAN}${line}${RESET}`) - console.log(`${CYAN} ${BOLD}Upstream OpenCode Versions${RESET}`) + console.log(`${CYAN} ${BOLD}Upstream OpenCode Releases${RESET}`) console.log(`${CYAN}${line}${RESET}`) console.log() console.log(` Remote: ${config.upstreamRemote} (${config.upstreamRepo})`) diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index dade2dfbf2..1cecf99ac1 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -5,6 +5,10 @@ * Merges upstream OpenCode releases into the Altimate Code fork with * automatic conflict resolution and branding transforms. * + * Only published GitHub releases can be merged — arbitrary commits or + * non-release tags are rejected. This ensures we only pick up stable, + * released changes from upstream. + * * Usage: * bun run script/upstream/merge.ts --version v1.2.21 * bun run script/upstream/merge.ts --version v1.2.21 --dry-run @@ -24,6 +28,7 @@ import * as logger from "./utils/logger" import { RESET, BOLD, DIM, CYAN, GREEN, RED, YELLOW, MAGENTA, bold, dim, cyan, green, red, yellow, banner } from "./utils/logger" import { loadConfig, repoRoot, type MergeConfig, type StringReplacement } from "./utils/config" import { createReport, addFileReport, printSummary, writeReport, type MergeReport, type FileReport, type Change } from "./utils/report" +import { validateRelease, getReleaseTags } from "./utils/github" // --------------------------------------------------------------------------- // CLI argument parsing @@ -32,7 +37,7 @@ import { createReport, addFileReport, printSummary, writeReport, type MergeRepor const { values: args } = parseArgs({ options: { version: { type: "string", short: "v" }, - commit: { type: "string", short: "c" }, + "include-prerelease": { type: "boolean", default: false }, "base-branch": { type: "string", default: "main" }, "dry-run": { type: "boolean", default: false }, "no-push": { type: "boolean", default: false }, @@ -72,14 +77,18 @@ function printUsage(): void { bun run script/upstream/merge.ts --continue Resume after conflict resolution ${bold("OPTIONS")} - --version, -v Upstream version tag to merge (e.g., v1.2.21) - --commit, -c Merge a specific commit instead of a tag - --base-branch Branch to merge into (default: main) - --dry-run Analyze changes without modifying the repo - --no-push Skip pushing the merge branch to origin - --continue Resume after manual conflict resolution - --author Override the merge commit author - --help, -h Show this help message + --version, -v Upstream release tag to merge (e.g., v1.2.21) + --include-prerelease Allow merging pre-release versions + --base-branch Branch to merge into (default: main) + --dry-run Analyze changes without modifying the repo + --no-push Skip pushing the merge branch to origin + --continue Resume after manual conflict resolution + --author Override the merge commit author + --help, -h Show this help message + + ${bold("NOTE")} + Only published GitHub releases can be merged. Arbitrary commits or + non-release tags are rejected to ensure stability. ${bold("EXAMPLES")} ${dim("# Standard merge")} @@ -511,18 +520,20 @@ async function main(): Promise { return } - // Determine merge target + // Determine merge target — only published releases are allowed const version = args.version - const commitRef = args.commit - const mergeRef = version || commitRef - if (!mergeRef) { - logger.error("--version or --commit is required") + if (!version) { + logger.error("--version is required") logger.info("Usage: bun run script/upstream/merge.ts --version v1.2.21") - logger.info(" bun run script/upstream/merge.ts --commit abc123") + logger.info("") + logger.info("List available releases:") + logger.info(" bun run script/upstream/list-versions.ts") process.exit(1) } + const mergeRef = version + const baseBranch = args["base-branch"] || config.baseBranch banner(`Upstream Merge: ${mergeRef}`) @@ -556,25 +567,35 @@ async function main(): Promise { await git.fetchRemote(config.upstreamRemote) logger.success("Upstream fetched") - // Validate version tag exists (if using --version) - if (version) { - const tags = await git.getTags(config.upstreamRemote) - if (!tags.includes(version)) { - logger.error(`Tag '${version}' not found on ${config.upstreamRemote}`) - // Show recent tags - const recent = tags - .filter((t) => t.startsWith("v")) - .sort() - .reverse() - .slice(0, 10) + // Validate version is a published GitHub release (not just a tag) + logger.info(`Validating '${version}' is a published release on ${config.upstreamRepo}...`) + const validation = await validateRelease(config.upstreamRepo, version, { + includePrerelease: Boolean(args["include-prerelease"]), + }) + + if (!validation.valid) { + logger.error(validation.reason!) + + if (validation.reason?.includes("pre-release") && !args["include-prerelease"]) { + logger.info("Pass --include-prerelease to allow merging pre-release versions") + } + + // Show recent releases for reference + try { + const recentTags = await getReleaseTags(config.upstreamRepo) + const recent = recentTags.slice(0, 10) if (recent.length > 0) { - logger.info(`Recent tags: ${recent.join(", ")}`) + logger.info(`Recent releases: ${recent.join(", ")}`) } - process.exit(1) + } catch { + // Best effort } - logger.success(`Tag '${version}' exists`) + + process.exit(1) } + logger.success(`'${version}' is a published release (${validation.release!.published_at.split("T")[0]})`) + const currentBranchName = await git.getCurrentBranch() logger.info(`Current branch: ${cyan(currentBranchName)}`) logger.info(`Target: ${cyan(mergeRef)}`) @@ -600,7 +621,7 @@ async function main(): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) const backupBranch = `backup/${currentBranchName}-${timestamp}` - const versionSlug = (version || commitRef!).replace(/[^a-zA-Z0-9.-]/g, "-") + const versionSlug = version.replace(/[^a-zA-Z0-9.-]/g, "-") const mergeBranch = `upstream/merge-${versionSlug}` // Create backup branch at current position @@ -635,12 +656,8 @@ async function main(): Promise { logger.step(4, TOTAL_STEPS, `Merging ${mergeRef}`) - const upstreamRef = version - ? `${config.upstreamRemote}/${version.replace(/^v/, "")}` - : commitRef! - - // Try the tag directly first, fall back to the ref - const mergeTarget = version || commitRef! + // Merge the release tag directly + const mergeTarget = version const mergeResult = await git.merge(mergeTarget) if (mergeResult.success) { diff --git a/script/upstream/utils/github.ts b/script/upstream/utils/github.ts new file mode 100644 index 0000000000..a687135b5c --- /dev/null +++ b/script/upstream/utils/github.ts @@ -0,0 +1,119 @@ +// GitHub Releases API utilities for upstream merge tooling. +// Ensures we only merge from official published releases, not arbitrary +// tags or commits. + +import { execSync } from "child_process" + +export interface GitHubRelease { + tag_name: string + name: string + prerelease: boolean + draft: boolean + published_at: string + html_url: string +} + +/** + * Fetch published releases from a GitHub repository using the `gh` CLI. + * Returns only non-draft releases, sorted by publish date (newest first). + * + * By default filters out pre-releases; pass `includePrerelease: true` to include them. + */ +export async function fetchReleases( + repo: string, + options: { limit?: number; includePrerelease?: boolean } = {}, +): Promise { + const { limit, includePrerelease = false } = options + + // `gh api --paginate` with `--jq '.[]'` unpacks each page's array into + // individual JSON objects (one per line). We then pipe to external `jq -s` + // to slurp them into a single array for filtering and slicing. + // Note: `gh api` does not support `--slurp` — that's a jq-only flag. + const condition = includePrerelease + ? "select(.draft == false)" + : "select(.draft == false and .prerelease == false)" + const slice = limit != null ? ` | .[0:${limit}]` : "" + const jqFilter = `[.[] | ${condition}]${slice}` + + const cmd = `gh api repos/${repo}/releases --paginate --jq '.[]' | jq -s '${jqFilter}'` + + try { + const output = execSync(cmd, { + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + timeout: 30_000, + }).trim() + + if (!output) return [] + return JSON.parse(output) as GitHubRelease[] + } catch (e: any) { + throw new Error(`Failed to fetch releases from ${repo}: ${e.message || e}`) + } +} + +/** + * Check whether a specific version tag corresponds to a published GitHub release. + * Returns the release info if found, or null if the tag is not a release. + */ +export async function getRelease( + repo: string, + tag: string, +): Promise { + const cmd = `gh api repos/${repo}/releases/tags/${tag} --jq '{tag_name, name, prerelease, draft, published_at, html_url}'` + + try { + const output = execSync(cmd, { + encoding: "utf-8", + maxBuffer: 1024 * 1024, + timeout: 15_000, + }).trim() + + if (!output) return null + const release = JSON.parse(output) as GitHubRelease + if (release.draft) return null + return release + } catch { + return null + } +} + +/** + * Get all release tag names from a GitHub repository. + * Returns only non-draft, non-prerelease tags by default. + */ +export async function getReleaseTags( + repo: string, + options: { includePrerelease?: boolean } = {}, +): Promise { + const releases = await fetchReleases(repo, options) + return releases.map((r) => r.tag_name) +} + +/** + * Validate that a version string corresponds to a published release. + * Returns an object with the validation result and, on failure, a helpful message. + */ +export async function validateRelease( + repo: string, + version: string, + options: { includePrerelease?: boolean } = {}, +): Promise<{ valid: boolean; release?: GitHubRelease; reason?: string }> { + const release = await getRelease(repo, version) + + if (!release) { + return { + valid: false, + reason: `'${version}' is not a published GitHub release on ${repo}. Only released versions can be merged.`, + } + } + + if (release.prerelease && !options.includePrerelease) { + return { + valid: false, + release, + reason: `'${version}' is a pre-release, not a stable release. Use --include-prerelease to allow pre-releases.`, + } + } + + return { valid: true, release } +}