diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 154e240ef6..b6336f3dae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -829,14 +829,16 @@ jobs: with: bun-version: "1.3.14" + - name: Install merge tooling deps + run: bun install + working-directory: script/upstream + - name: Add upstream remote run: | git remote add upstream https://github.com/anomalyco/opencode.git || true git fetch upstream --quiet --no-tags - - - name: Install merge tooling deps - run: bun install - working-directory: script/upstream + baseline=$(bun run script/upstream/analyze.ts --print-baseline) + git fetch upstream "refs/tags/${baseline}:refs/upstream-baseline/${baseline}" --no-tags - name: Run marker parser tests run: bun test diff --git a/script/upstream/README.md b/script/upstream/README.md index facd3c3e28..260e4ea169 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -9,6 +9,10 @@ We maintain a fork of [anomalyco/opencode](https://github.com/anomalyco/opencode - **Custom code is protected** — files in `keepOurs` patterns are never overwritten by upstream. Our Python engine, bridge layer, altimate tools, and CI workflows stay untouched. - **Upstream-only packages are skipped** — platform packages (app, web, desktop, etc.), nix, SST infra, and translated READMEs are discarded during merge. +### Major versions + +`upstreamBaseline` in `utils/config.ts` records the upstream tag the fork is based on (currently `v1.17.9`). Bump it after each upstream merge lands. `list-versions.ts` lists and suggests only releases with that major version, and reports when newer-major tags exist. Both `merge.ts` and `bridge-merge.ts` reject cross-major targets, including in dry-run mode, before changing Git state. Upstream v2 replaces `packages/opencode` and the plugin, tool, session and server APIs, so moving to it is a re-port (Altimate features as v2 plugins on a thin branded build), not a merge with these scripts. + ## Prerequisites ```bash @@ -126,6 +130,7 @@ The merge script prints a `gh pr create` command at the end. Review the diff, th | `bun run script/upstream/analyze.ts` | Check `altimate_change` marker integrity | | `bun run script/upstream/analyze.ts --markers --base main` | Check PR for missing markers | | `bun run script/upstream/analyze.ts --markers --base main --strict` | Same, but fail CI on warnings | +| `bun run script/upstream/analyze.ts --print-baseline` | Print the pinned upstream baseline tag | | `bun run script/upstream/verify-restructure.ts` | Verify branch restructure | ## Configuration @@ -280,7 +285,16 @@ script/upstream/ ## CI Integration -The **Marker Guard** CI job (in `.github/workflows/ci.yml`) runs automatically on every PR. It checks whether any upstream-shared files (`packages/opencode/src/`) have new code without `altimate_change` markers. This prevents accidental overwrites during future upstream merges. +The **Marker Guard** CI job (in `.github/workflows/ci.yml`) runs automatically on every PR. It checks whether any upstream-shared files (such as `packages/opencode/src/`) have new code without `altimate_change` markers. Ownership is determined from the pinned `upstreamBaseline` tag in `utils/config.ts`, so moving upstream branches to a new major version does not disable checks on v1 files. This prevents accidental overwrites during future upstream merges. + +After installing the merge tooling dependencies, CI fetches the upstream remote and then explicitly fetches the baseline tag into the dedicated `refs/upstream-baseline/` namespace. The analyzer reads this full ref to avoid collisions with same-named fork tags or local branches: + +```bash +baseline=$(bun run script/upstream/analyze.ts --print-baseline) +git fetch upstream "refs/tags/${baseline}:refs/upstream-baseline/${baseline}" --no-tags +``` + +If the namespaced baseline ref is missing locally, the analyzer attempts this fetch once and retries the check. If the ref is still unavailable, it warns on stderr with the tag and fetch command, then falls back to directory-based ownership checks. Fetch the tag into this namespace to restore the baseline-based check. ```bash # What CI runs: diff --git a/script/upstream/analyze.test.ts b/script/upstream/analyze.test.ts index 2d082cf116..a8a60d58c1 100644 --- a/script/upstream/analyze.test.ts +++ b/script/upstream/analyze.test.ts @@ -1,5 +1,7 @@ -import { describe, test, expect } from "bun:test" -import { parseDiffForMarkerWarnings, computeMarkedLines } from "./analyze" +import { describe, test, expect, spyOn } from "bun:test" +import childProcess from "child_process" +import { parseDiffForMarkerWarnings, computeMarkedLines, getUpstreamFiles, isUpstreamShared } from "./analyze" +import { loadConfig, repoRoot } from "./utils/config" // Helper to create a unified diff string from lines function makeDiff(hunks: string): string { @@ -10,6 +12,144 @@ index abc1234..def5678 100644 ${hunks}` } +describe("upstream baseline ownership", () => { + const file = "packages/opencode/src/session/prompt.ts" + + test("uses the dedicated baseline ref, never moving dev/main branches, and flags unmarked code", () => { + const config = loadConfig() + const lsTree = spyOn(childProcess, "execFileSync").mockReturnValue(`${file}\n`) + try { + expect(getUpstreamFiles(config).has(file)).toBe(true) + expect(lsTree).toHaveBeenCalledWith( + "git", + ["ls-tree", "-r", "--name-only", `refs/upstream-baseline/${config.upstreamBaseline}`], + expect.objectContaining({ cwd: repoRoot() }), + ) + expect(isUpstreamShared(file, config)).toBe(true) + expect(isUpstreamShared("packages/opencode/src/fork-only.ts", config)).toBe(false) + expect(lsTree).toHaveBeenCalledTimes(1) + const diff = makeDiff("@@ -1,1 +1,2 @@\n const existing = true\n+const unmarked = true") + expect(parseDiffForMarkerWarnings(file, diff)).toEqual([ + expect.objectContaining({ file, line: 2, context: "const unmarked = true" }), + ]) + } finally { + lsTree.mockRestore() + } + }) + + test("changing the baseline refreshes the cached file list", () => { + const config = { ...loadConfig(), upstreamBaseline: "v1.18.34" } + const lsTree = spyOn(childProcess, "execFileSync").mockReturnValue("packages/opencode/src/new.ts\n") + try { + expect(getUpstreamFiles(config).has(file)).toBe(false) + expect(isUpstreamShared("packages/opencode/src/new.ts", config)).toBe(true) + expect(lsTree).toHaveBeenCalledTimes(1) + expect(lsTree.mock.calls[0][1]).toEqual(["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.18.34"]) + } finally { + lsTree.mockRestore() + } + }) + + test("missing baseline is fetched once and used for ownership checks", () => { + const config = { ...loadConfig(), upstreamBaseline: "v1.99.998", upstreamRemote: "baseline-upstream" } + const exec = spyOn(childProcess, "execFileSync") + .mockImplementationOnce(() => { + throw new Error("Not a valid object name") + }) + .mockReturnValueOnce("") + .mockReturnValueOnce(`${file}\n`) + const warn = spyOn(console, "warn").mockImplementation(() => {}) + try { + expect(getUpstreamFiles(config).has(file)).toBe(true) + expect(isUpstreamShared(file, config)).toBe(true) + expect(isUpstreamShared("packages/opencode/src/fork-only.ts", config)).toBe(false) + expect(exec.mock.calls.map((call) => call[1])).toEqual([ + ["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.99.998"], + ["fetch", config.upstreamRemote, "refs/tags/v1.99.998:refs/upstream-baseline/v1.99.998", "--no-tags", "--quiet"], + ["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.99.998"], + ]) + expect(exec.mock.calls[1][0]).toBe("git") + expect(exec.mock.calls[1][2]).toEqual({ cwd: repoRoot(), stdio: "ignore" }) + expect(warn).not.toHaveBeenCalled() + } finally { + exec.mockRestore() + warn.mockRestore() + } + }) + + test("failed baseline fetch warns with fetch instructions and falls back to the directory heuristic", () => { + const config = { ...loadConfig(), upstreamBaseline: "v1.99.999" } + const exec = spyOn(childProcess, "execFileSync").mockImplementation(() => { + throw new Error("Not a valid object name") + }) + const warn = spyOn(console, "warn").mockImplementation(() => {}) + try { + expect(getUpstreamFiles(config).size).toBe(0) + expect(isUpstreamShared(file, config)).toBe(true) + expect(isUpstreamShared("packages/opencode/src/altimate/custom.ts", config)).toBe(false) + expect(isUpstreamShared("packages/other/src/custom.ts", config)).toBe(false) + expect(exec.mock.calls.map((call) => call[1])).toEqual([ + ["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.99.999"], + ["fetch", config.upstreamRemote, "refs/tags/v1.99.999:refs/upstream-baseline/v1.99.999", "--no-tags", "--quiet"], + ["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.99.999"], + ]) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toContain("upstream baseline 'v1.99.999' is unavailable") + expect(warn.mock.calls[0][0]).toContain("git fetch upstream refs/tags/v1.99.999:refs/upstream-baseline/v1.99.999 --no-tags") + expect(warn.mock.calls[0][0]).toContain("Falling back to directory-based ownership checks") + } finally { + exec.mockRestore() + warn.mockRestore() + } + }) + + test.each([true, false])("ignores same-named fork tags and branches (baseline ref present: %s)", (present) => { + const config = { ...loadConfig(), upstreamBaseline: present ? "v1.99.996" : "v1.99.997" } + const tag = config.upstreamBaseline + const ref = `refs/upstream-baseline/${tag}` + const forkFile = "packages/opencode/src/fork-only.ts" + const refs = new Map([ + [tag, `${forkFile}\n`], + [`refs/tags/${tag}`, `${forkFile}\n`], + [`refs/heads/${tag}`, `${forkFile}\n`], + ]) + if (present) refs.set(ref, `${file}\n`) + const exec = spyOn(childProcess, "execFileSync").mockImplementation((_command, args) => { + if (args?.[0] === "fetch") { + refs.set(ref, `${file}\n`) + return "" + } + const output = refs.get(args?.[3] ?? "") + if (output === undefined) throw new Error("Not a valid object name") + return output + }) + try { + expect(getUpstreamFiles(config)).toEqual(new Set([file])) + expect(isUpstreamShared(forkFile, config)).toBe(false) + expect(exec.mock.calls.filter((call) => call[1]?.[0] === "ls-tree").map((call) => call[1])).toEqual( + Array.from({ length: present ? 1 : 2 }, () => ["ls-tree", "-r", "--name-only", ref]), + ) + expect(exec.mock.calls.filter((call) => call[1]?.[0] === "fetch").map((call) => call[1])).toEqual( + present ? [] : [["fetch", config.upstreamRemote, `refs/tags/${tag}:${ref}`, "--no-tags", "--quiet"]], + ) + expect(refs.get(`refs/tags/${tag}`)).toBe(`${forkFile}\n`) + expect(refs.get(`refs/heads/${tag}`)).toBe(`${forkFile}\n`) + } finally { + exec.mockRestore() + } + }) + + test("--print-baseline prints only the tag", () => { + const result = childProcess.spawnSync(process.execPath, ["run", "script/upstream/analyze.ts", "--print-baseline"], { + cwd: repoRoot(), + encoding: "utf-8", + }) + expect(result.status).toBe(0) + expect(result.stdout).toBe(`${loadConfig().upstreamBaseline}\n`) + expect(result.stderr).toBe("") + }) +}) + describe("parseDiffForMarkerWarnings", () => { test("returns no warnings for empty diff", () => { expect(parseDiffForMarkerWarnings("file.ts", "")).toEqual([]) diff --git a/script/upstream/analyze.ts b/script/upstream/analyze.ts index 8cec376903..949e52f564 100644 --- a/script/upstream/analyze.ts +++ b/script/upstream/analyze.ts @@ -36,6 +36,7 @@ const { values: args } = parseArgs({ branding: { type: "boolean", default: false }, markers: { type: "boolean", default: false }, "require-markers": { type: "boolean", default: false }, + "print-baseline": { type: "boolean", default: false }, "audit-fixes": { type: "boolean", default: false }, strict: { type: "boolean", default: false }, base: { type: "string" }, @@ -622,6 +623,7 @@ function printUsage(): void { --version, -v Upstream version to analyze --branding Scan codebase for upstream branding leaks --markers Check changed files for missing altimate_change markers + --print-baseline Print only the upstream baseline tag (for CI) --audit-fixes List all upstream_fix: markers (bug fixes we made to upstream code) --base Base branch for --markers comparison (default: HEAD) --strict Exit with code 1 on warnings (for CI) @@ -678,30 +680,46 @@ function getChangedFiles(base?: string): string[] { } } -// Cache for upstream file existence checks (populated on first use) -let _upstreamFilesCache: Set | null = null +// Cache by baseline so checks with a different configuration cannot reuse stale ownership. +let _upstreamFilesCache: { ref: string; files: Set } | null = null -function getUpstreamFiles(config: MergeConfig): Set { - if (_upstreamFilesCache) return _upstreamFilesCache - const { execSync } = require("child_process") +export function getUpstreamFiles(config: MergeConfig): Set { + const tag = config.upstreamBaseline + // Fork releases can use the same tag or branch name as the upstream baseline. + const ref = `refs/upstream-baseline/${tag}` + if (_upstreamFilesCache?.ref === ref) return _upstreamFilesCache.files + const { execFileSync } = require("child_process") const root = repoRoot() - const refs = [`${config.upstreamRemote}/dev`, `${config.upstreamRemote}/main`] - for (const ref of refs) { + const readFiles = () => + execFileSync("git", ["ls-tree", "-r", "--name-only", ref], { + cwd: root, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }) + try { + let output: string try { - const output = execSync(`git ls-tree -r --name-only ${ref}`, { - cwd: root, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - }) - _upstreamFilesCache = new Set(output.trim().split("\n").filter(Boolean)) - return _upstreamFilesCache + output = readFiles() } catch { - // ref not available, try next + try { + execFileSync("git", ["fetch", config.upstreamRemote, `refs/tags/${tag}:${ref}`, "--no-tags", "--quiet"], { + cwd: root, + stdio: "ignore", + }) + } catch { + // Retry the local ref even when the best-effort fetch fails. + } + output = readFiles() } + _upstreamFilesCache = { ref, files: new Set(output.trim().split("\n").filter(Boolean)) } + } catch { + console.warn( + `Warning: upstream baseline '${tag}' is unavailable. Fetch it with: git fetch ${config.upstreamRemote} refs/tags/${tag}:${ref} --no-tags --quiet. Falling back to directory-based ownership checks.`, + ) + _upstreamFilesCache = { ref, files: new Set() } } - // Upstream not available — return empty set (all files treated as ours-only) - _upstreamFilesCache = new Set() - return _upstreamFilesCache + return _upstreamFilesCache.files } // altimate_change start — exclude test files, generated code, and config files from marker checks @@ -721,7 +739,7 @@ const markerExcludePatterns = [ ] // altimate_change end -function isUpstreamShared(file: string, config: MergeConfig): boolean { +export function isUpstreamShared(file: string, config: MergeConfig): boolean { const { minimatch } = require("minimatch") if (config.keepOurs.some((p: string) => minimatch(file, p))) return false if (config.skipFiles.some((p: string) => minimatch(file, p))) return false @@ -898,20 +916,6 @@ function checkFileForMarkers(file: string, base?: string): MarkerWarning[] { } function runMarkerCheck(config: MergeConfig, base?: string, strict?: boolean): number { - const { execSync } = require("child_process") - const root = repoRoot() - - // Ensure upstream remote exists and is fetched so we can check file existence - try { - execSync(`git remote get-url ${config.upstreamRemote}`, { cwd: root, stdio: "ignore" }) - // --no-tags: upstream tags (v0.x, v1.x) collide with our own release tags - // (see git.ts's fetchRemote(), which this call duplicates but historically omitted the flag). - execSync(`git fetch ${config.upstreamRemote} --quiet --no-tags`, { cwd: root, stdio: "ignore" }) - } catch { - // If upstream remote doesn't exist, fall back to pattern-only checks - logger.warn(`Could not fetch '${config.upstreamRemote}' remote — falling back to pattern-based detection`) - } - const changedFiles = getChangedFiles(base) const sharedFiles = changedFiles.filter((f) => isUpstreamShared(f, config)) @@ -1030,13 +1034,17 @@ function runRequireMarkersCheck(config: MergeConfig, strict?: boolean): number { // --------------------------------------------------------------------------- async function main(): Promise { + const config = loadConfig() + if (args["print-baseline"]) { + console.log(config.upstreamBaseline) + return + } + if (args.help) { printUsage() process.exit(0) } - const config = loadConfig() - const hasVersion = Boolean(args.version) const hasBranding = Boolean(args.branding) const hasMarkers = Boolean(args.markers) diff --git a/script/upstream/bridge-merge.ts b/script/upstream/bridge-merge.ts index 38bb37a0e1..54b7e30016 100644 --- a/script/upstream/bridge-merge.ts +++ b/script/upstream/bridge-merge.ts @@ -46,6 +46,7 @@ import { writeFileSync, existsSync, readFileSync, rmSync } from "fs" import { loadConfig, repoRoot, type MergeConfig } from "./utils/config.ts" import * as logger from "./utils/logger.ts" import { bold, cyan, dim, green, yellow, red } from "./utils/logger.ts" +import { assertSameMajor } from "./utils/version.ts" // --------------------------------------------------------------------------- // CLI args @@ -477,6 +478,8 @@ async function editSessionLlmTs(report: string[]): Promise { async function main(): Promise { const config = loadConfig() + assertSameMajor(VERSION, config.upstreamBaseline) + const { minimatch } = await import("minimatch") const report: string[] = [] report.push(`# Bridge Merge Report — overlay ${VERSION}\n`) diff --git a/script/upstream/list-versions.ts b/script/upstream/list-versions.ts index dbfc27fffd..03a1567561 100644 --- a/script/upstream/list-versions.ts +++ b/script/upstream/list-versions.ts @@ -19,6 +19,7 @@ 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" +import { compareVersionMajor, versionMajor } from "./utils/version" // --------------------------------------------------------------------------- // CLI @@ -193,16 +194,30 @@ async function main(): Promise { logger.info(`Fetching releases from GitHub (${config.upstreamRepo})...`) const releases = await fetchReleases(config.upstreamRepo, { includePrerelease: Boolean(args["include-prerelease"]), - limit: limit === Infinity ? undefined : limit, }) - // Extract tags and sort by version - const versionTags = releases + // Filter by the fork's major before applying --limit, so newer majors cannot + // hide compatible releases. Keep newer majors only for the informational note. + const releaseTags = releases .map((r) => r.tag_name) + .filter((tag) => { + try { + versionMajor(tag) + return true + } catch { + return false // Unrelated release tags (e.g., github-v1) are not merge targets. + } + }) .sort(compareVersions) + const versionTags = releaseTags.filter((tag) => compareVersionMajor(tag, config.upstreamBaseline) === 0) + const newerMajorTags = releaseTags.filter((tag) => compareVersionMajor(tag, config.upstreamBaseline) > 0) + + if (newerMajorTags.length > 0) { + logger.warn(`${newerMajorTags.length} newer-major release tag(s) excluded (highest: ${newerMajorTags[0]}); major upgrades require a re-port, not a merge.`) + } if (versionTags.length === 0) { - logger.warn("No published releases found on upstream") + logger.warn(`No published releases found for upstream baseline ${config.upstreamBaseline}'s major`) process.exit(0) } diff --git a/script/upstream/merge-guard.test.ts b/script/upstream/merge-guard.test.ts new file mode 100644 index 0000000000..66dfd92804 --- /dev/null +++ b/script/upstream/merge-guard.test.ts @@ -0,0 +1,31 @@ +import { describe, test, expect } from "bun:test" +import path from "path" + +const root = path.resolve(import.meta.dir, "../..") + +function gitOutput(...args: string[]): string { + const result = Bun.spawnSync(["git", ...args], { cwd: root }) + expect(result.exitCode).toBe(0) + return result.stdout.toString() +} + +describe("upstream merge entry-point guards", () => { + for (const entryPoint of ["merge.ts", "bridge-merge.ts"]) { + test(`${entryPoint} rejects a major upgrade without changing the repository`, () => { + const head = gitOutput("rev-parse", "HEAD") + const status = gitOutput("status", "--porcelain") + const refs = gitOutput("for-each-ref") + + const result = Bun.spawnSync( + [process.execPath, "run", `script/upstream/${entryPoint}`, "--version", "v2.0.0", "--dry-run"], + { cwd: root }, + ) + + expect(gitOutput("rev-parse", "HEAD")).toBe(head) + expect(gitOutput("status", "--porcelain")).toBe(status) + expect(gitOutput("for-each-ref")).toBe(refs) + expect(result.exitCode).not.toBe(0) + expect(result.stdout.toString() + result.stderr.toString()).toContain("re-port, not a merge") + }) + } +}) diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index b1dce594db..39f10e5b07 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -29,6 +29,7 @@ import { RESET, BOLD, DIM, CYAN, GREEN, RED, YELLOW, MAGENTA, bold, dim, cyan, g 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" +import { assertSameMajor } from "./utils/version" // --------------------------------------------------------------------------- // CLI argument parsing @@ -605,6 +606,8 @@ async function main(): Promise { process.exit(1) } + assertSameMajor(version, config.upstreamBaseline) + const mergeRef = version const baseBranch = args["base-branch"] || config.baseBranch @@ -812,6 +815,8 @@ async function continueAfterConflicts(config: MergeConfig): Promise { process.exit(1) } + assertSameMajor(state.version, config.upstreamBaseline) + logger.info(`Resuming merge of ${cyan(state.version)}`) logger.info(`Merge branch: ${cyan(state.mergeBranch)}`) diff --git a/script/upstream/utils/config.ts b/script/upstream/utils/config.ts index c9816004da..971ad53414 100644 --- a/script/upstream/utils/config.ts +++ b/script/upstream/utils/config.ts @@ -14,6 +14,8 @@ export interface StringReplacement { export interface MergeConfig { upstreamRemote: string upstreamRepo: string + /** Upstream tag the fork is based on; ownership checks use it. Bump when a merge lands. */ + upstreamBaseline: string originRepo: string baseBranch: string changeMarker: string @@ -253,6 +255,7 @@ const cliBinaryRules: StringReplacement[] = [ export const defaultConfig: MergeConfig = { upstreamRemote: "upstream", upstreamRepo: "anomalyco/opencode", + upstreamBaseline: "v1.17.9", originRepo: "AltimateAI/altimate-code", baseBranch: "main", changeMarker: "altimate_change", diff --git a/script/upstream/utils/version.ts b/script/upstream/utils/version.ts new file mode 100644 index 0000000000..69699f0608 --- /dev/null +++ b/script/upstream/utils/version.ts @@ -0,0 +1,21 @@ +/** Parse a release tag's major version, rejecting branch names and malformed tags. */ +export function versionMajor(version: string): number { + const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version) + if (!match || !Number.isSafeInteger(Number(match[1]))) { + throw new Error(`Invalid upstream version '${version}': expected a release tag like v1.17.9`) + } + return Number(match[1]) +} + +/** Negative for an older major, zero for the same major, positive for a newer major. */ +export function compareVersionMajor(version: string, baseline: string): number { + return versionMajor(version) - versionMajor(baseline) +} + +export function assertSameMajor(version: string, baseline: string): void { + if (compareVersionMajor(version, baseline) === 0) return + throw new Error( + `Cannot merge ${version}: its major differs from upstream baseline ${baseline}. ` + + "A major upgrade (for example, v2) is a re-port, not a merge; see 'Major versions' in script/upstream/README.md.", + ) +} diff --git a/script/upstream/version.test.ts b/script/upstream/version.test.ts new file mode 100644 index 0000000000..da31ccb6a4 --- /dev/null +++ b/script/upstream/version.test.ts @@ -0,0 +1,31 @@ +import { describe, test, expect } from "bun:test" +import { assertSameMajor, compareVersionMajor, versionMajor } from "./utils/version" + +describe("upstream version major", () => { + test("allows minor and patch releases within the baseline's major", () => { + expect(compareVersionMajor("v1.18.34", "v1.17.9")).toBe(0) + expect(() => assertSameMajor("v1.18.34", "v1.17.9")).not.toThrow() + }) + + test("rejects cross-major merges with re-port guidance", () => { + expect(compareVersionMajor("v2.0.29", "v1.17.9")).toBeGreaterThan(0) + expect(compareVersionMajor("v1.17.9", "v2.0.29")).toBeLessThan(0) + expect(() => assertSameMajor("v2.0.29", "v1.17.9")).toThrow("re-port, not a merge") + expect(() => assertSameMajor("v1.17.9", "v2.0.29")).toThrow("re-port, not a merge") + expect(() => assertSameMajor("v0.1.0", "v1.17.9")).toThrow("major differs") + }) + + test("accepts version tags with optional v, prerelease, and build metadata", () => { + expect(versionMajor("1.18.34")).toBe(1) + expect(versionMajor("v2.0.29-beta.1+build.42")).toBe(2) + expect(compareVersionMajor("v1.18.34-rc.1", "v1.17.9")).toBe(0) + }) + + test("rejects malformed targets and baselines", () => { + for (const version of ["", "dev", "upstream/main", "v2", "v1.2", "v1.x.3", "v01.2.3", "v1.2.3-", "v1.2.3-beta..1", "v1.2.3junk"]) { + expect(() => versionMajor(version)).toThrow("Invalid upstream version") + expect(() => compareVersionMajor(version, "v1.17.9")).toThrow("Invalid upstream version") + expect(() => compareVersionMajor("v1.17.9", version)).toThrow("Invalid upstream version") + } + }) +})