From ae9685eda89329c61cb9fa5f2b8c283bc1d5f52d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 15 Oct 2026 01:46:25 -0700 Subject: [PATCH 1/3] fix(upstream): pin Marker Guard to the upstream baseline tag; refuse cross-major merges Upstream moved `dev` to OpenCode v2, which has no `packages/opencode` paths. Marker Guard listed upstream files from the moving `dev`/`main` branches, so every v1 file looked fork-only and unmarked edits passed `--strict`. - `upstreamBaseline` (`v1.17.9`) in `utils/config.ts`; `analyze.ts` reads ownership from that tag only, warns and falls back if it is missing, and adds `--print-baseline`. CI fetches the baseline tag explicitly. - `merge.ts` and `bridge-merge.ts` reject targets whose major differs from the baseline; `list-versions.ts` lists only same-major releases and notes newer majors. - Tests for baseline ownership, fallback and the major-version helper. Closes #1504 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 +++-- script/upstream/README.md | 16 ++++++- script/upstream/analyze.test.ts | 77 +++++++++++++++++++++++++++++++- script/upstream/analyze.ts | 67 ++++++++++++--------------- script/upstream/bridge-merge.ts | 3 ++ script/upstream/list-versions.ts | 23 ++++++++-- script/upstream/merge.ts | 5 +++ script/upstream/utils/config.ts | 3 ++ script/upstream/utils/version.ts | 21 +++++++++ script/upstream/version.test.ts | 31 +++++++++++++ 10 files changed, 207 insertions(+), 49 deletions(-) create mode 100644 script/upstream/utils/version.ts create mode 100644 script/upstream/version.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 154e240ef6..856a7eda32 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/tags/${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..fbcc505dcc 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: + +```bash +baseline=$(bun run script/upstream/analyze.ts --print-baseline) +git fetch upstream "refs/tags/${baseline}:refs/tags/${baseline}" --no-tags +``` + +If the baseline ref is missing locally, the analyzer warns on stderr with the tag and fetch command, then falls back to directory-based ownership checks. Fetch the tag 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..1529460b92 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,77 @@ index abc1234..def5678 100644 ${hunks}` } +describe("upstream baseline ownership", () => { + const file = "packages/opencode/src/session/prompt.ts" + + test("uses the baseline tag, 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", 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", "v1.18.34"]) + } finally { + lsTree.mockRestore() + } + }) + + test("missing baseline warns with fetch instructions and falls back to the directory heuristic", () => { + const config = { ...loadConfig(), upstreamBaseline: "v1.99.999" } + const lsTree = 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(lsTree).toHaveBeenCalledTimes(1) + 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/tags/v1.99.999 --no-tags") + expect(warn.mock.calls[0][0]).toContain("Falling back to directory-based ownership checks") + } finally { + lsTree.mockRestore() + warn.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..b78dd334d1 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,29 @@ 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 ref = config.upstreamBaseline + 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) { - 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 - } catch { - // ref not available, try next - } + try { + const output = execFileSync("git", ["ls-tree", "-r", "--name-only", ref], { + cwd: root, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }) + _upstreamFilesCache = { ref, files: new Set(output.trim().split("\n").filter(Boolean)) } + } catch { + console.warn( + `Warning: upstream baseline '${ref}' is unavailable. Fetch it with: git fetch ${config.upstreamRemote} refs/tags/${ref}:refs/tags/${ref} --no-tags. 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 +722,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 +899,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 +1017,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.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..309ee430d6 --- /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("v2.0.29", "v1.17.9")).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") + } + }) +}) From 32c3c2626c4962c6fbc21d76917ff75ff079d489 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 15 Oct 2026 01:58:18 -0700 Subject: [PATCH 2/3] test(upstream): cover the cross-major guard end to end; fetch a missing baseline tag - `merge-guard.test.ts` runs the real `merge.ts` and `bridge-merge.ts` with a v2 target and asserts exit 1, the re-port message, and unchanged HEAD/status. - `getUpstreamFiles` fetches the baseline tag once when it is missing locally before warning and falling back, so local `--markers` runs keep working. Co-Authored-By: Claude Opus 5.5 --- script/upstream/analyze.test.ts | 39 ++++++++++++++++++++++++++--- script/upstream/analyze.ts | 19 ++++++++++++-- script/upstream/merge-guard.test.ts | 29 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 script/upstream/merge-guard.test.ts diff --git a/script/upstream/analyze.test.ts b/script/upstream/analyze.test.ts index 1529460b92..474cbcb438 100644 --- a/script/upstream/analyze.test.ts +++ b/script/upstream/analyze.test.ts @@ -50,9 +50,36 @@ describe("upstream baseline ownership", () => { } }) - test("missing baseline warns with fetch instructions and falls back to the directory heuristic", () => { + 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", config.upstreamBaseline], + ["fetch", config.upstreamRemote, "refs/tags/v1.99.998:refs/tags/v1.99.998", "--no-tags", "--quiet"], + ["ls-tree", "-r", "--name-only", config.upstreamBaseline], + ]) + 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 lsTree = spyOn(childProcess, "execFileSync").mockImplementation(() => { + const exec = spyOn(childProcess, "execFileSync").mockImplementation(() => { throw new Error("Not a valid object name") }) const warn = spyOn(console, "warn").mockImplementation(() => {}) @@ -61,13 +88,17 @@ describe("upstream baseline ownership", () => { 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(lsTree).toHaveBeenCalledTimes(1) + expect(exec.mock.calls.map((call) => call[1])).toEqual([ + ["ls-tree", "-r", "--name-only", config.upstreamBaseline], + ["fetch", config.upstreamRemote, "refs/tags/v1.99.999:refs/tags/v1.99.999", "--no-tags", "--quiet"], + ["ls-tree", "-r", "--name-only", config.upstreamBaseline], + ]) 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/tags/v1.99.999 --no-tags") expect(warn.mock.calls[0][0]).toContain("Falling back to directory-based ownership checks") } finally { - lsTree.mockRestore() + exec.mockRestore() warn.mockRestore() } }) diff --git a/script/upstream/analyze.ts b/script/upstream/analyze.ts index b78dd334d1..e27c0c3de5 100644 --- a/script/upstream/analyze.ts +++ b/script/upstream/analyze.ts @@ -688,13 +688,28 @@ export function getUpstreamFiles(config: MergeConfig): Set { if (_upstreamFilesCache?.ref === ref) return _upstreamFilesCache.files const { execFileSync } = require("child_process") const root = repoRoot() - try { - const output = execFileSync("git", ["ls-tree", "-r", "--name-only", ref], { + 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 { + output = readFiles() + } catch { + try { + execFileSync("git", ["fetch", config.upstreamRemote, `refs/tags/${ref}:refs/tags/${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( diff --git a/script/upstream/merge-guard.test.ts b/script/upstream/merge-guard.test.ts new file mode 100644 index 0000000000..5847a92847 --- /dev/null +++ b/script/upstream/merge-guard.test.ts @@ -0,0 +1,29 @@ +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 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(result.exitCode).not.toBe(0) + expect(result.stdout.toString() + result.stderr.toString()).toContain("re-port, not a merge") + }) + } +}) From 0cb93b790db2b2b1325f53a4ec44c465623f03ca Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 15 Oct 2026 02:07:14 -0700 Subject: [PATCH 3/3] fix(upstream): read the baseline from a dedicated ref namespace A bare `v1.17.9` lookup could resolve to a local branch or a fork-owned tag of the same name. The baseline is now fetched into and read only from `refs/upstream-baseline/` (analyzer fallback fetch and CI alike). Also: replace a duplicated assertion with the reversed cross-major case, and have the merge-guard test compare all refs before and after. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- script/upstream/README.md | 6 ++-- script/upstream/analyze.test.ts | 56 +++++++++++++++++++++++------ script/upstream/analyze.ts | 8 +++-- script/upstream/merge-guard.test.ts | 2 ++ script/upstream/version.test.ts | 2 +- 6 files changed, 58 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 856a7eda32..b6336f3dae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -838,7 +838,7 @@ jobs: git remote add upstream https://github.com/anomalyco/opencode.git || true git fetch upstream --quiet --no-tags baseline=$(bun run script/upstream/analyze.ts --print-baseline) - git fetch upstream "refs/tags/${baseline}:refs/tags/${baseline}" --no-tags + 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 fbcc505dcc..260e4ea169 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -287,14 +287,14 @@ script/upstream/ 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: +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/tags/${baseline}" --no-tags +git fetch upstream "refs/tags/${baseline}:refs/upstream-baseline/${baseline}" --no-tags ``` -If the baseline ref is missing locally, the analyzer warns on stderr with the tag and fetch command, then falls back to directory-based ownership checks. Fetch the tag to restore the baseline-based check. +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 474cbcb438..a8a60d58c1 100644 --- a/script/upstream/analyze.test.ts +++ b/script/upstream/analyze.test.ts @@ -15,14 +15,14 @@ ${hunks}` describe("upstream baseline ownership", () => { const file = "packages/opencode/src/session/prompt.ts" - test("uses the baseline tag, never moving dev/main branches, and flags unmarked code", () => { + 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", config.upstreamBaseline], + ["ls-tree", "-r", "--name-only", `refs/upstream-baseline/${config.upstreamBaseline}`], expect.objectContaining({ cwd: repoRoot() }), ) expect(isUpstreamShared(file, config)).toBe(true) @@ -44,7 +44,7 @@ describe("upstream baseline ownership", () => { 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", "v1.18.34"]) + expect(lsTree.mock.calls[0][1]).toEqual(["ls-tree", "-r", "--name-only", "refs/upstream-baseline/v1.18.34"]) } finally { lsTree.mockRestore() } @@ -64,9 +64,9 @@ describe("upstream baseline ownership", () => { 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", config.upstreamBaseline], - ["fetch", config.upstreamRemote, "refs/tags/v1.99.998:refs/tags/v1.99.998", "--no-tags", "--quiet"], - ["ls-tree", "-r", "--name-only", config.upstreamBaseline], + ["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" }) @@ -89,13 +89,13 @@ describe("upstream baseline ownership", () => { 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", config.upstreamBaseline], - ["fetch", config.upstreamRemote, "refs/tags/v1.99.999:refs/tags/v1.99.999", "--no-tags", "--quiet"], - ["ls-tree", "-r", "--name-only", config.upstreamBaseline], + ["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/tags/v1.99.999 --no-tags") + 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() @@ -103,6 +103,42 @@ describe("upstream baseline ownership", () => { } }) + 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(), diff --git a/script/upstream/analyze.ts b/script/upstream/analyze.ts index e27c0c3de5..949e52f564 100644 --- a/script/upstream/analyze.ts +++ b/script/upstream/analyze.ts @@ -684,7 +684,9 @@ function getChangedFiles(base?: string): string[] { let _upstreamFilesCache: { ref: string; files: Set } | null = null export function getUpstreamFiles(config: MergeConfig): Set { - const ref = config.upstreamBaseline + 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() @@ -701,7 +703,7 @@ export function getUpstreamFiles(config: MergeConfig): Set { output = readFiles() } catch { try { - execFileSync("git", ["fetch", config.upstreamRemote, `refs/tags/${ref}:refs/tags/${ref}`, "--no-tags", "--quiet"], { + execFileSync("git", ["fetch", config.upstreamRemote, `refs/tags/${tag}:${ref}`, "--no-tags", "--quiet"], { cwd: root, stdio: "ignore", }) @@ -713,7 +715,7 @@ export function getUpstreamFiles(config: MergeConfig): Set { _upstreamFilesCache = { ref, files: new Set(output.trim().split("\n").filter(Boolean)) } } catch { console.warn( - `Warning: upstream baseline '${ref}' is unavailable. Fetch it with: git fetch ${config.upstreamRemote} refs/tags/${ref}:refs/tags/${ref} --no-tags. Falling back to directory-based ownership checks.`, + `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() } } diff --git a/script/upstream/merge-guard.test.ts b/script/upstream/merge-guard.test.ts index 5847a92847..66dfd92804 100644 --- a/script/upstream/merge-guard.test.ts +++ b/script/upstream/merge-guard.test.ts @@ -14,6 +14,7 @@ describe("upstream merge entry-point guards", () => { 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"], @@ -22,6 +23,7 @@ describe("upstream merge entry-point guards", () => { 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/version.test.ts b/script/upstream/version.test.ts index 309ee430d6..da31ccb6a4 100644 --- a/script/upstream/version.test.ts +++ b/script/upstream/version.test.ts @@ -11,7 +11,7 @@ describe("upstream version major", () => { 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("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") })