diff --git a/packages/core/test/pack-pins.test.ts b/packages/core/test/pack-pins.test.ts new file mode 100644 index 0000000..4c87bc7 --- /dev/null +++ b/packages/core/test/pack-pins.test.ts @@ -0,0 +1,59 @@ +// Packaging pin assertions (#212): the opencode triple-pin moves together, +// every release manifest declares an explicit `files` allowlist with no test +// paths, and the `--release` rendering keeps the ` ` contract +// `publish.yml` parses. File reads only: no network, no registry, no pack. +// Structural fixes (a peer range, narrower allowlists, publish parsing, +// the runtime-matrix wording) route to S9 via CROSS-SQUAD REQUEST. +import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { releasePackages, tarballSlug, workspacePackages } from "../../../scripts/packages.ts" + +const root = path.resolve(import.meta.dir, "../../..") +const manifest = async (dir: string) => + JSON.parse(await readFile(path.join(root, "packages", dir, "package.json"), "utf8")) as Record + +describe("the opencode triple-pin moves together", () => { + test("@opencode/plugin, @opencode/sdk and the e2e CLI share one version", async () => { + const opencode = await manifest("opencode") + const plugin = opencode.dependencies?.["@opencode/plugin"] as string + const sdk = opencode.devDependencies?.["@opencode/sdk"] as string + expect(plugin).toMatch(/^\d+\.\d+\.\d+$/) + expect(sdk).toBe(plugin) + const e2e = await readFile(path.join(root, ".github/workflows/e2e-feedback.yml"), "utf8") + const cliPins = [...e2e.matchAll(/@opencode\/cli-linux-x64@([0-9A-Za-z._-]+)/g)].map((match) => match[1]) + expect(cliPins.length).toBeGreaterThan(0) + expect(new Set(cliPins).size).toBe(1) + expect(cliPins[0]).toBe(plugin) + }) +}) + +describe("release tarball allowlists", () => { + test("every release manifest declares an explicit files allowlist with no test paths", async () => { + for (const pkg of await releasePackages(root)) { + const files = (await manifest(pkg.dir)).files as unknown + expect(Array.isArray(files) && (files as unknown[]).length > 0).toBe(true) + for (const entry of files as string[]) { + expect(typeof entry).toBe("string") + expect(entry).not.toMatch(/test|spec|__tests__/i) + } + } + }) + + test("fleet and web stay private (never released)", async () => { + const all = await workspacePackages(root) + for (const dir of ["fleet", "web"]) { + expect(all.find((pkg) => pkg.dir === dir)?.private).toBe(true) + } + }) +}) + +describe("the --release rendering contract", () => { + test('every line is " " with the slug derived from the manifest name', async () => { + for (const pkg of await releasePackages(root)) { + const line = `${pkg.dir} ${tarballSlug(pkg.name)}` + expect(line).toMatch(/^\S+ \S+$/) + expect(tarballSlug(pkg.name)).not.toContain(" ") + } + }) +}) diff --git a/packages/opencode/src/pr.ts b/packages/opencode/src/pr.ts index a5c8fca..3ddc254 100644 --- a/packages/opencode/src/pr.ts +++ b/packages/opencode/src/pr.ts @@ -1,6 +1,10 @@ -// Release PR opener backed by the GitHub CLI. It pushes only the run's -// integration branch (never the base branch) and opens a draft PR for a human -// to review and merge. +// Factory release PR opener backed by the GitHub CLI. Every factory push +// path goes through here: it pushes only the run's `factory/` integration +// branch — `branch === base` or a non-`factory/` branch is refused before +// any push — and opens a draft PR for a human to review and merge. (Other +// push paths exist outside the factory: `es improve --open-pr` pushes +// `improve/*` branches on its own human-run path; phase fast-forwards and +// fleet landings never touch the base branch; only a human merges.) import type { PrOpener } from "@heretek-ai/es-core" import { buildPrArgs, git, run } from "@heretek-ai/es-core" diff --git a/packages/opencode/test/pr-guard.test.ts b/packages/opencode/test/pr-guard.test.ts new file mode 100644 index 0000000..c3a3a0d --- /dev/null +++ b/packages/opencode/test/pr-guard.test.ts @@ -0,0 +1,80 @@ +// PR-opener guard (#238): `ghPrOpener.open()` refuses `branch === base` +// and non-`factory/` branches before any git call, and builds the draft-PR +// argv through the shared builder. No network and no `gh` invocation: every +// refusal throws before the push, and the draft shape is asserted on the +// argv (the `gh pr create` spawn itself never runs in tests). +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { buildPrArgs } from "@heretek-ai/es-core" +import { ghPrOpener } from "../src/pr.ts" + +const open = (root: string, overrides: { branch: string; base?: string; preset?: string } = { branch: "x" }) => + ghPrOpener.open({ + root, + branch: overrides.branch, + base: overrides.base ?? "rewrite", + title: "t", + body: "b", + ...(overrides.preset !== undefined ? { preset: overrides.preset } : {}), + }) + +describe("ghPrOpener.open() branch guard", () => { + test("branch === base is refused", async () => { + const root = await mkdtemp(path.join(tmpdir(), "es-pr-guard-")) + try { + await expect(open(root, { branch: "rewrite", base: "rewrite" })).rejects.toThrow(/refusing to push "rewrite"/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("non-factory/ branches are refused", async () => { + const root = await mkdtemp(path.join(tmpdir(), "es-pr-guard-")) + try { + for (const branch of ["main", "feature/x", "improve/20260101", "factory"]) { + await expect(open(root, { branch })).rejects.toThrow(/refusing to push/) + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("the refusal precedes any git call (a non-repo root still reports the refusal)", async () => { + // `root` is not a git repository: had the push run first, the failure + // would be git's, not the guard's. The guard's error proves the order + // (`pr.ts` checks the branch before `git push --set-upstream`). + const root = await mkdtemp(path.join(tmpdir(), "es-pr-guard-")) + try { + await open(root, { branch: "main", base: "rewrite" }) + expect.unreachable("a non-factory/ branch must be refused") + } catch (error) { + expect((error as Error).message).toMatch(/refusing to push "main"/) + expect((error as Error).message).not.toMatch(/git/i) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("the branch guard runs before the preset-base guard", async () => { + const root = await mkdtemp(path.join(tmpdir(), "es-pr-guard-")) + try { + await open(root, { branch: "main", base: "main", preset: "self-dogfood" }) + expect.unreachable("branch == base must be refused first") + } catch (error) { + expect((error as Error).message).toMatch(/refusing to push "main"/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +describe("release PR argv", () => { + test("a factory branch opens a draft PR against the base", () => { + const argv = buildPrArgs({ branch: "factory/run-1/integration", base: "rewrite", title: "t", body: "b" }) + expect(argv.slice(0, 4)).toEqual(["gh", "pr", "create", "--draft"]) + expect(argv.slice(argv.indexOf("--head") + 1, argv.indexOf("--head") + 2)).toEqual(["factory/run-1/integration"]) + expect(argv.slice(argv.indexOf("--base") + 1, argv.indexOf("--base") + 2)).toEqual(["rewrite"]) + }) +}) diff --git a/packages/opencode/test/release.test.ts b/packages/opencode/test/release.test.ts new file mode 100644 index 0000000..c3ffcf5 --- /dev/null +++ b/packages/opencode/test/release.test.ts @@ -0,0 +1,109 @@ +// Release base-branch safety against a local bare remote (#238): the +// factory release pushes a `factory/` branch only, `branch == base` (or a +// non-`factory/` branch) is refused before anything is pushed, and the base +// SHA on the remote is byte-identical afterwards. Local bare-remote fixture +// only (no real remote, no network); `gh` is never invoked — the authorized +// push runs the same `git push --set-upstream origin :` +// argv `pr.ts` uses, and the draft shape is asserted on the shared argv. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { buildPrArgs, git } from "@heretek-ai/es-core" +import { ghPrOpener } from "../src/pr.ts" + +const BASE = "rewrite" +const BRANCH = "factory/run-test-1/integration" + +interface Remote { + readonly root: string + readonly remote: string + readonly baseSha: string + cleanup(): Promise +} + +/** A repo with a base branch pushed to a local bare remote, plus one factory-branch commit. */ +async function bareRemote(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "es-release-")) + const remote = await mkdtemp(path.join(tmpdir(), "es-release-remote-")) + const cleanup = async () => { + await rm(root, { recursive: true, force: true }) + await rm(remote, { recursive: true, force: true }) + } + try { + await git(remote, ["init", "--bare", "-q"]) + await git(root, ["init", "-q", "-b", BASE]) + await writeFile(path.join(root, "README.md"), "# release fixture\n") + await git(root, ["add", "-A"]) + await git(root, ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]) + await git(root, ["remote", "add", "origin", remote]) + await git(root, ["push", "-q", "-u", "origin", `${BASE}:${BASE}`]) + const baseSha = (await git(root, ["rev-parse", BASE])).stdout.trim() + await git(root, ["checkout", "-q", "-b", BRANCH]) + await writeFile(path.join(root, "work.txt"), "factory work\n") + await git(root, ["add", "-A"]) + await git(root, ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "factory work"]) + return { root, remote, baseSha, cleanup } + } catch (error) { + await cleanup() + throw error + } +} + +const remoteSha = async (remote: string, ref: string) => + (await git(remote, ["rev-parse", "--verify", ref])).stdout.trim() + +const remoteBranches = async (remote: string) => + (await git(remote, ["for-each-ref", "--format=%(refname)", "refs/heads/"])).stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + +describe("release against a local bare remote", () => { + test("branch == base is refused and the remote is untouched", async () => { + const fx = await bareRemote() + try { + await expect(ghPrOpener.open({ root: fx.root, branch: BASE, base: BASE, title: "t", body: "b" })).rejects.toThrow( + `refusing to push "${BASE}"`, + ) + expect(await remoteBranches(fx.remote)).toEqual(["refs/heads/rewrite"]) + expect(await remoteSha(fx.remote, "refs/heads/rewrite")).toBe(fx.baseSha) + } finally { + await fx.cleanup() + } + }) + + test("a non-factory/ branch is refused and the remote is untouched", async () => { + const fx = await bareRemote() + try { + await expect( + ghPrOpener.open({ root: fx.root, branch: "feature/x", base: BASE, title: "t", body: "b" }), + ).rejects.toThrow(/refusing to push/) + expect(await remoteBranches(fx.remote)).toEqual(["refs/heads/rewrite"]) + expect(await remoteSha(fx.remote, "refs/heads/rewrite")).toBe(fx.baseSha) + } finally { + await fx.cleanup() + } + }) + + test("the authorized push lands only the factory branch; the base SHA is byte-identical", async () => { + const fx = await bareRemote() + try { + const local = (await git(fx.root, ["rev-parse", BRANCH])).stdout.trim() + // The same argv `pr.ts` pushes with (the `gh pr create` spawn never + // runs in tests: no `gh` invocation here). + await git(fx.root, ["push", "--set-upstream", "origin", `${BRANCH}:${BRANCH}`]) + expect(await remoteBranches(fx.remote)).toEqual( + ["refs/heads/factory/run-test-1/integration", "refs/heads/rewrite"].sort(), + ) + expect(await remoteSha(fx.remote, `refs/heads/${BRANCH}`)).toBe(local) + expect(await remoteSha(fx.remote, "refs/heads/rewrite")).toBe(fx.baseSha) + // The release PR itself is a draft against the base. + const argv = buildPrArgs({ branch: BRANCH, base: BASE, title: "t", body: "b" }) + expect(argv.slice(0, 4)).toEqual(["gh", "pr", "create", "--draft"]) + expect(argv.slice(argv.indexOf("--base") + 1, argv.indexOf("--base") + 2)).toEqual([BASE]) + } finally { + await fx.cleanup() + } + }) +})