From 59846d4a85beae677ba9e8bf6ba36c4e410a37fb Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Tue, 8 Sep 2026 15:50:16 +0530 Subject: [PATCH 1/4] test(release): characterize existing release policy --- .../analyze-commits-title.unit.test.ts | 61 +++ .../fixtures/release-history.fixtures.ts | 48 ++ ...emantic-release-characterization-plugin.js | 24 + ...mantic-release-history.integration.test.ts | 456 ++++++++++++++++++ .../semantic-release-path-filter.unit.test.ts | 104 +++- 5 files changed, 687 insertions(+), 6 deletions(-) create mode 100644 apps/cli/scripts/analyze-commits-title.unit.test.ts create mode 100644 apps/cli/scripts/fixtures/release-history.fixtures.ts create mode 100644 apps/cli/scripts/fixtures/semantic-release-characterization-plugin.js create mode 100644 apps/cli/scripts/semantic-release-history.integration.test.ts diff --git a/apps/cli/scripts/analyze-commits-title.unit.test.ts b/apps/cli/scripts/analyze-commits-title.unit.test.ts new file mode 100644 index 0000000000..40736df70a --- /dev/null +++ b/apps/cli/scripts/analyze-commits-title.unit.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { analyzeCommits } from "./analyze-commits-title.js"; + +type ReleaseType = "major" | "minor" | "patch" | null; + +const logger = { log: () => {} }; + +function analyze(messages: readonly unknown[]): ReleaseType { + return analyzeCommits( + {}, + { + commits: messages.map((message, index) => ({ hash: `commit-${index}`, message })), + logger, + }, + ); +} + +describe("analyzeCommits title-only release policy", () => { + test.each([ + ["feat: add capability", "minor"], + ["FEAT: add capability", "minor"], + ["fix: correct behavior", "patch"], + ["FIX: correct behavior", "patch"], + ["perf: improve startup", "patch"], + ["revert: restore behavior", "patch"], + ["PERF: improve startup", null], + ["REVERT: restore behavior", null], + ["Feat: add capability", null], + ["Fix: correct behavior", null], + ["ci!: change the release contract", "major"], + ["chore(scope)!: change the release contract", "major"], + ["feat(api/v2)!: replace an endpoint", "major"], + ["fix(scope.with:punctuation): correct behavior", "patch"], + ["docs: explain behavior\n\nBREAKING CHANGE: words in the body are ignored", null], + ["fix: first line wins\r\n\r\nfeat!: body lines are ignored", "patch"], + ["", null], + [" ", null], + [undefined, null], + [null, null], + [42, null], + ["feat:add capability", null], + ["feat(scope) add capability", null], + ["prefix feat: add capability", null], + ] satisfies ReadonlyArray)( + "classifies %j as %s", + (message, expected) => { + expect(analyze([message])).toBe(expected); + }, + ); + + test.each([ + [["docs: no release", "fix: patch", "feat: minor"], "minor"], + [["feat: minor", "ci!: major", "fix: patch"], "major"], + [["docs: no release", "chore: still no release"], null], + ] satisfies ReadonlyArray)( + "selects the highest bump from %j", + (messages, expected) => { + expect(analyze(messages)).toBe(expected); + }, + ); +}); diff --git a/apps/cli/scripts/fixtures/release-history.fixtures.ts b/apps/cli/scripts/fixtures/release-history.fixtures.ts new file mode 100644 index 0000000000..6756122182 --- /dev/null +++ b/apps/cli/scripts/fixtures/release-history.fixtures.ts @@ -0,0 +1,48 @@ +export const TRAINS = [ + { train: "cli", legacyPrefix: "v", namespace: "cli@" }, + { train: "config", legacyPrefix: "config-v", namespace: "config@" }, +] as const; + +export const HISTORICAL_INTERVALS = [ + { + name: "CLI v2.116.0 to the first v2.117.0 beta", + train: "cli", + version: "2.116.0", + commits: [ + { message: "feat(cli): add supabase workers new (#6261)", path: "apps/cli/workers-new.ts" }, + { message: "chore(repo): migrate live tasks to Turborepo (#6343)", path: "turbo.json" }, + ], + currentVersion: "2.117.0-beta.1", + hybridVersion: "2.117.0-beta.1", + }, + { + name: "config-v0.1.0 to config-v0.1.1", + train: "config", + version: "0.1.0", + commits: [ + { + message: "fix(deps): bump the npm-major group across 1 directory with 28 updates (#6430)", + path: "packages/config/package.json", + }, + ], + currentVersion: "0.1.1", + hybridVersion: "0.1.1-beta.1", + }, + { + name: "config-v0.1.1 to config-v0.2.0", + train: "config", + version: "0.1.1", + commits: [ + { + message: "feat(cli): add config diff command (#6295)", + path: "packages/config/src/config-diff.ts", + }, + { + message: "ci(config): add Slack notifications to the config release pipeline (#6436)", + path: ".github/workflows/release-config.yml", + }, + ], + currentVersion: "0.2.0", + hybridVersion: "0.2.0-beta.1", + }, +] as const; diff --git a/apps/cli/scripts/fixtures/semantic-release-characterization-plugin.js b/apps/cli/scripts/fixtures/semantic-release-characterization-plugin.js new file mode 100644 index 0000000000..223c487429 --- /dev/null +++ b/apps/cli/scripts/fixtures/semantic-release-characterization-plugin.js @@ -0,0 +1,24 @@ +import { analyzeCommits as analyzeTitles } from "../analyze-commits-title.js"; +import { + filterCommitsToPackage, + generateNotes as generateConfigNotes, +} from "../../../../packages/config/scripts/semantic-release-path-filter.ts"; + +async function selectedCommits(pluginConfig, context) { + return pluginConfig.train === "config" + ? filterCommitsToPackage(context.commits, context.cwd) + : context.commits; +} + +export async function analyzeCommits(pluginConfig, context) { + const commits = await selectedCommits(pluginConfig, context); + return analyzeTitles(pluginConfig, { ...context, commits }); +} + +export async function generateNotes(pluginConfig, context) { + if (pluginConfig.train === "config") { + return generateConfigNotes(pluginConfig, context); + } + + return context.commits.map(({ message }) => `- ${message.split(/\r?\n/, 1)[0]}`).join("\n"); +} diff --git a/apps/cli/scripts/semantic-release-history.integration.test.ts b/apps/cli/scripts/semantic-release-history.integration.test.ts new file mode 100644 index 0000000000..323fd2cf14 --- /dev/null +++ b/apps/cli/scripts/semantic-release-history.integration.test.ts @@ -0,0 +1,456 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import semanticRelease, { type Result } from "semantic-release"; +import { afterEach, describe, expect, test } from "vitest"; +import { HISTORICAL_INTERVALS, TRAINS } from "./fixtures/release-history.fixtures.ts"; + +const CHARACTERIZATION_PLUGIN = fileURLToPath( + new URL("./fixtures/semantic-release-characterization-plugin.js", import.meta.url), +); +const CURRENT_CONFIG_PLUGIN = fileURLToPath( + new URL("../../../packages/config/scripts/semantic-release-path-filter.ts", import.meta.url), +); +const GIT_CONFIG = [ + "-c", + "user.name=release-characterization", + "-c", + "user.email=release-characterization@supabase.local", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", +]; + +interface History { + readonly root: string; + readonly repo: string; + readonly origin: string; + readonly legacyTag: string; + readonly baselineSha: string; + nextFile: number; +} + +interface RunOptions { + readonly tagFormat: string; + readonly train: "cli" | "config"; + readonly branches?: ReadonlyArray< + string | { readonly name: string; readonly prerelease: string; readonly channel: string } + >; + readonly plugin?: string; +} + +const temporaryRoots: string[] = []; + +async function git(cwd: string, args: readonly string[]): Promise { + const proc = Bun.spawn(["git", ...GIT_CONFIG, ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed with exit code ${exitCode}: ${stderr.trim()}`); + } + return stdout.trim(); +} + +async function gitExitCode(cwd: string, args: readonly string[]): Promise { + const proc = Bun.spawn(["git", ...GIT_CONFIG, ...args], { + cwd, + stdout: "ignore", + stderr: "ignore", + }); + return proc.exited; +} + +async function createHistory(legacyTag: string): Promise { + const root = await mkdtemp(join(tmpdir(), "semantic-release-history-")); + temporaryRoots.push(root); + const repo = join(root, "repo"); + const origin = join(root, "origin.git"); + await mkdir(repo); + await git(root, ["init", "--bare", "-q", origin]); + await git(repo, ["init", "-b", "main", "-q"]); + await writeFile( + join(repo, "package.json"), + '{"name":"release-characterization","version":"0.0.0"}\n', + ); + await git(repo, ["add", "package.json"]); + await git(repo, ["commit", "-m", "chore: establish release baseline"]); + const baselineSha = await git(repo, ["rev-parse", "HEAD"]); + await git(repo, ["tag", legacyTag]); + await git(repo, ["remote", "add", "origin", pathToFileURL(origin).href]); + await git(repo, ["push", "-u", "origin", "main", "--tags"]); + await git(repo, ["checkout", "-b", "develop", "-q"]); + await git(repo, ["push", "-u", "origin", "develop"]); + return { root, repo, origin, legacyTag, baselineSha, nextFile: 0 }; +} + +async function commit( + history: History, + message: string, + path = "apps/cli/release-characterization.ts", +): Promise { + const fullPath = join(history.repo, path); + await mkdir(dirname(fullPath), { recursive: true }); + history.nextFile += 1; + await writeFile(fullPath, `export const revision = ${history.nextFile};\n`); + await git(history.repo, ["add", path]); + await git(history.repo, ["commit", "-m", message]); + return git(history.repo, ["rev-parse", "HEAD"]); +} + +async function pushCurrentBranch(history: History): Promise { + await git(history.repo, ["push", "origin", "HEAD"]); +} + +async function tagRelease( + history: History, + tag: string, + channel?: "beta" | "latest", +): Promise { + await git(history.repo, ["tag", tag]); + if (channel) { + const noteChannel = channel === "latest" ? null : channel; + await git(history.repo, [ + "notes", + "--ref", + "semantic-release", + "add", + "-f", + "-m", + JSON.stringify({ channels: [noteChannel] }), + `${tag}^{commit}`, + ]); + await git(history.repo, ["push", "origin", "refs/notes/semantic-release"]); + } + await git(history.repo, ["push", "origin", `refs/tags/${tag}`]); +} + +function releaseEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const name of [ + "CI", + "GITHUB_ACTIONS", + "GITHUB_REF", + "GITHUB_REF_NAME", + "GITHUB_HEAD_REF", + "GITHUB_BASE_REF", + "GITHUB_EVENT_NAME", + ]) { + delete env[name]; + } + return env; +} + +async function runRelease(history: History, options: RunOptions): Promise { + return semanticRelease( + { + branches: options.branches ?? [ + "main", + { name: "develop", prerelease: "beta", channel: "beta" }, + ], + tagFormat: options.tagFormat, + repositoryUrl: pathToFileURL(history.origin).href, + dryRun: true, + noCi: true, + plugins: [[options.plugin ?? CHARACTERIZATION_PLUGIN, { train: options.train }]], + }, + { cwd: history.repo, env: releaseEnvironment() }, + ); +} + +function expectRelease(result: Result, version: string, gitTag: string): void { + expect(result).not.toBe(false); + if (result === false) { + throw new Error("semantic-release unexpectedly reported no release"); + } + expect(result.nextRelease.version).toBe(version); + expect(result.nextRelease.gitTag).toBe(gitTag); +} + +async function withCompatibilityTags( + history: History, + tags: ReadonlyArray<{ readonly name: string; readonly target: string }>, + effect: () => Promise, +): Promise { + const legacyTargetBefore = await git(history.repo, [ + "rev-parse", + `refs/tags/${history.legacyTag}`, + ]); + for (const tag of tags) { + await git(history.repo, ["tag", tag.name, tag.target]); + } + try { + return await effect(); + } finally { + for (const tag of tags) { + await git(history.repo, ["tag", "-d", tag.name]); + } + expect(await git(history.repo, ["rev-parse", `refs/tags/${history.legacyTag}`])).toBe( + legacyTargetBefore, + ); + } +} + +afterEach(async () => { + while (temporaryRoots.length > 0) { + const root = temporaryRoots.pop(); + if (root) { + await rm(root, { recursive: true, force: true }); + } + } +}); + +describe.each(TRAINS)("$train namespaced release history", (train) => { + const selectedPath = + train.train === "config" + ? "packages/config/src/release-characterization.ts" + : "apps/cli/release-characterization.ts"; + const tagFormat = `${train.namespace}\${version}`; + + test("moves from a legacy stable through two namespaced betas to a namespaced stable", async () => { + const history = await createHistory(`${train.legacyPrefix}1.2.3`); + await commit(history, "feat: add capability", selectedPath); + await pushCurrentBranch(history); + + const firstBeta = await withCompatibilityTags( + history, + [{ name: `${train.namespace}1.2.3`, target: history.baselineSha }], + () => runRelease(history, { train: train.train, tagFormat }), + ); + expectRelease(firstBeta, "1.3.0-beta.1", `${train.namespace}1.3.0-beta.1`); + expect( + await gitExitCode(history.repo, [ + "show-ref", + "--verify", + "--quiet", + `refs/tags/${train.namespace}1.2.3`, + ]), + ).toBe(1); + + await tagRelease(history, `${train.namespace}1.3.0-beta.1`, "beta"); + await commit(history, "fix: correct the beta", selectedPath); + await pushCurrentBranch(history); + const secondBeta = await runRelease(history, { train: train.train, tagFormat }); + expectRelease(secondBeta, "1.3.0-beta.2", `${train.namespace}1.3.0-beta.2`); + + await tagRelease(history, `${train.namespace}1.3.0-beta.2`, "beta"); + await git(history.repo, ["checkout", "main", "-q"]); + await git(history.repo, ["merge", "--ff-only", "develop"]); + await pushCurrentBranch(history); + const stable = await withCompatibilityTags( + history, + [{ name: `${train.namespace}1.2.3`, target: history.baselineSha }], + () => runRelease(history, { train: train.train, tagFormat }), + ); + expectRelease(stable, "1.3.0", `${train.namespace}1.3.0`); + + expect(await git(history.repo, ["rev-parse", `refs/tags/${history.legacyTag}`])).toBe( + history.baselineSha, + ); + expect((await git(history.repo, ["tag", "--list", `${train.namespace}*`])).split("\n")).toEqual( + [`${train.namespace}1.3.0-beta.1`, `${train.namespace}1.3.0-beta.2`], + ); + }, 30_000); + + test("continues an active legacy beta at beta.2", async () => { + const history = await createHistory(`${train.legacyPrefix}1.2.3`); + const betaSha = await commit(history, "feat: add capability", selectedPath); + await tagRelease(history, `${train.legacyPrefix}1.3.0-beta.1`, "beta"); + await commit(history, "fix: continue beta testing", selectedPath); + await pushCurrentBranch(history); + + const result = await withCompatibilityTags( + history, + [ + { name: `${train.namespace}1.2.3`, target: history.baselineSha }, + { name: `${train.namespace}1.3.0-beta.1`, target: betaSha }, + ], + () => runRelease(history, { train: train.train, tagFormat }), + ); + + expectRelease(result, "1.3.0-beta.2", `${train.namespace}1.3.0-beta.2`); + }); + + test("plans a reviewed stable hotfix directly from the legacy stable", async () => { + const history = await createHistory(`${train.legacyPrefix}1.2.3`); + await git(history.repo, ["checkout", "main", "-q"]); + await commit(history, "fix: reviewed production hotfix", selectedPath); + await pushCurrentBranch(history); + + const result = await withCompatibilityTags( + history, + [{ name: `${train.namespace}1.2.3`, target: history.baselineSha }], + () => runRelease(history, { train: train.train, tagFormat }), + ); + + expectRelease(result, "1.2.4", `${train.namespace}1.2.4`); + }); + + test("characterizes major, patch, and no-release histories", async () => { + for (const scenario of [ + { message: "chore!: replace the public contract", version: "2.0.0-beta.1" }, + { message: "fix: correct behavior", version: "1.2.4-beta.1" }, + { message: "docs: clarify behavior", version: null }, + ]) { + const history = await createHistory(`${train.legacyPrefix}1.2.3`); + await commit(history, scenario.message, selectedPath); + await pushCurrentBranch(history); + const result = await withCompatibilityTags( + history, + [{ name: `${train.namespace}1.2.3`, target: history.baselineSha }], + () => runRelease(history, { train: train.train, tagFormat }), + ); + if (scenario.version === null) { + expect(result).toBe(false); + } else { + expectRelease(result, scenario.version, `${train.namespace}${scenario.version}`); + } + } + }, 30_000); + + test("accepts legacy and namespaced stable tags on the same commit", async () => { + const history = await createHistory(`${train.legacyPrefix}1.2.3`); + await git(history.repo, ["tag", `${train.namespace}1.2.3`, history.baselineSha]); + await git(history.repo, ["push", "origin", `refs/tags/${train.namespace}1.2.3`]); + await commit(history, "fix: correct behavior", selectedPath); + await pushCurrentBranch(history); + + const result = await runRelease(history, { train: train.train, tagFormat }); + + expectRelease(result, "1.2.4-beta.1", `${train.namespace}1.2.4-beta.1`); + expect(await git(history.repo, ["rev-parse", `refs/tags/${history.legacyTag}`])).toBe( + history.baselineSha, + ); + }); + + test("fetches beta channel notes and differs when they are missing", async () => { + const history = await createHistory(`${train.legacyPrefix}1.2.3`); + await git(history.repo, ["tag", `${train.namespace}1.2.3`, history.baselineSha]); + await git(history.repo, ["push", "origin", `refs/tags/${train.namespace}1.2.3`]); + const betaSha = await commit(history, "feat: add capability", selectedPath); + await tagRelease(history, `${train.namespace}1.3.0-beta.1`); + await commit(history, "fix: continue beta testing", selectedPath); + await pushCurrentBranch(history); + + const withoutNotes = await runRelease(history, { train: train.train, tagFormat }); + expectRelease(withoutNotes, "1.3.0-beta.1", `${train.namespace}1.3.0-beta.1`); + + await git(history.repo, [ + "notes", + "--ref", + "semantic-release", + "add", + "-f", + "-m", + JSON.stringify({ channels: ["beta"] }), + betaSha, + ]); + await git(history.repo, ["push", "origin", "refs/notes/semantic-release"]); + await git(history.repo, ["update-ref", "-d", "refs/notes/semantic-release"]); + expect( + await gitExitCode(history.repo, ["notes", "--ref", "semantic-release", "show", betaSha]), + ).toBe(1); + + const withFetchedNotes = await runRelease(history, { train: train.train, tagFormat }); + expectRelease(withFetchedNotes, "1.3.0-beta.2", `${train.namespace}1.3.0-beta.2`); + }); +}); + +test("CLI and config histories diverge independently in the same repository", async () => { + const history = await createHistory("v1.2.3"); + await git(history.repo, ["tag", "cli@1.2.3", history.baselineSha]); + await git(history.repo, ["tag", "config@0.4.1", history.baselineSha]); + await git(history.repo, ["push", "origin", "refs/tags/cli@1.2.3", "refs/tags/config@0.4.1"]); + await commit(history, "feat(cli): add a command", "apps/cli/new-command.ts"); + await commit(history, "fix(api): correct config parsing", "packages/config/src/parser.ts"); + await pushCurrentBranch(history); + + const cli = await runRelease(history, { train: "cli", tagFormat: "cli@${version}" }); + const config = await runRelease(history, { train: "config", tagFormat: "config@${version}" }); + + expectRelease(cli, "1.3.0-beta.1", "cli@1.3.0-beta.1"); + expectRelease(config, "0.4.2-beta.1", "config@0.4.2-beta.1"); +}); + +test("config ownership follows paths when conventional scope disagrees", async () => { + const history = await createHistory("config-v1.2.3"); + await git(history.repo, ["tag", "config@1.2.3", history.baselineSha]); + await git(history.repo, ["push", "origin", "refs/tags/config@1.2.3"]); + await commit(history, "feat(config): title claims config ownership", "apps/cli/not-config.ts"); + await commit(history, "fix(cli): actual diff owns config", "packages/config/src/owned.ts"); + await pushCurrentBranch(history); + + const result = await runRelease(history, { train: "config", tagFormat: "config@${version}" }); + + expectRelease(result, "1.2.4-beta.1", "config@1.2.4-beta.1"); +}); + +describe.each(HISTORICAL_INTERVALS)("historical interval: $name", (fixture) => { + test("records current and hybrid results", async () => { + const train = TRAINS.find(({ train }) => train === fixture.train); + if (!train) { + throw new Error(`unknown train ${fixture.train}`); + } + const history = await createHistory(`${train.legacyPrefix}${fixture.version}`); + for (const historicalCommit of fixture.commits) { + await commit(history, historicalCommit.message, historicalCommit.path); + } + await pushCurrentBranch(history); + + const current = await runRelease(history, { + train: fixture.train, + tagFormat: `${train.legacyPrefix}\${version}`, + branches: fixture.train === "config" ? ["develop"] : undefined, + plugin: fixture.train === "config" ? CURRENT_CONFIG_PLUGIN : CHARACTERIZATION_PLUGIN, + }); + expectRelease( + current, + fixture.currentVersion, + `${train.legacyPrefix}${fixture.currentVersion}`, + ); + + const hybrid = await withCompatibilityTags( + history, + [{ name: `${train.namespace}${fixture.version}`, target: history.baselineSha }], + () => + runRelease(history, { + train: fixture.train, + tagFormat: `${train.namespace}\${version}`, + }), + ); + expectRelease(hybrid, fixture.hybridVersion, `${train.namespace}${fixture.hybridVersion}`); + }); +}); + +test("the config difference from stock analysis is limited to title-only classification", async () => { + const history = await createHistory("config-v1.2.3"); + await commit( + history, + "docs: update migration guidance\n\nBREAKING CHANGE: body-only markers are ignored by the hybrid policy", + "packages/config/docs/migration.md", + ); + await pushCurrentBranch(history); + + const current = await runRelease(history, { + train: "config", + tagFormat: "config-v${version}", + branches: ["develop"], + plugin: CURRENT_CONFIG_PLUGIN, + }); + expectRelease(current, "2.0.0", "config-v2.0.0"); + + const hybrid = await withCompatibilityTags( + history, + [{ name: "config@1.2.3", target: history.baselineSha }], + () => runRelease(history, { train: "config", tagFormat: "config@${version}" }), + ); + expect(hybrid).toBe(false); +}); diff --git a/packages/config/scripts/semantic-release-path-filter.unit.test.ts b/packages/config/scripts/semantic-release-path-filter.unit.test.ts index 3afa18d4c3..c2ec7ff893 100644 --- a/packages/config/scripts/semantic-release-path-filter.unit.test.ts +++ b/packages/config/scripts/semantic-release-path-filter.unit.test.ts @@ -98,6 +98,11 @@ function fakeAnalyzeCommitsContext(commits: Commit[], cwd: string): AnalyzeCommi describe("semantic-release-path-filter", () => { let repoDir: string; let hashConfigOnly: string; + let hashConfigModified: string; + let hashConfigDeleted: string; + let hashRenameIntoConfig: string; + let hashRenameOutOfConfig: string; + let hashSquash: string; let hashCliOnly: string; let hashBoth: string; let hashPrefixTrap: string; @@ -118,6 +123,28 @@ describe("semantic-release-path-filter", () => { { "packages/config/src/foo.ts": "export const foo = 1;\n" }, "chore: seed packages/config/src/foo.ts", ); + hashConfigModified = await commitFiles( + repoDir, + { "packages/config/src/foo.ts": "export const foo = 2;\n" }, + "chore: modify packages/config/src/foo.ts", + ); + await git(repoDir, ["rm", "packages/config/src/foo.ts"]); + await git(repoDir, ["commit", "-m", "chore: delete packages/config/src/foo.ts"]); + hashConfigDeleted = (await git(repoDir, ["rev-parse", "HEAD"])).trim(); + + await commitFiles( + repoDir, + { "scratch/renamed.ts": "export const renamed = true;\n" }, + "chore: seed a file outside packages/config", + ); + await mkdir(join(repoDir, "packages/config/src"), { recursive: true }); + await git(repoDir, ["mv", "scratch/renamed.ts", "packages/config/src/renamed.ts"]); + await git(repoDir, ["commit", "-m", "chore: rename a file into packages/config"]); + hashRenameIntoConfig = (await git(repoDir, ["rev-parse", "HEAD"])).trim(); + await mkdir(join(repoDir, "scratch"), { recursive: true }); + await git(repoDir, ["mv", "packages/config/src/renamed.ts", "scratch/renamed-again.ts"]); + await git(repoDir, ["commit", "-m", "chore: rename a file out of packages/config"]); + hashRenameOutOfConfig = (await git(repoDir, ["rev-parse", "HEAD"])).trim(); hashCliOnly = await commitFiles( repoDir, { "apps/cli/src/bar.ts": "export const bar = 1;\n" }, @@ -142,6 +169,22 @@ describe("semantic-release-path-filter", () => { "chore: seed a non-ASCII path under packages/config", ); + await git(repoDir, ["checkout", "-b", "squashed", "-q"]); + await commitFiles( + repoDir, + { "apps/cli/src/squashed.ts": "export const first = 1;\n" }, + "chore: first commit that will be squashed", + ); + await commitFiles( + repoDir, + { "packages/config/src/squashed.ts": "export const second = 2;\n" }, + "chore: second commit that will be squashed", + ); + await git(repoDir, ["checkout", "main", "-q"]); + await git(repoDir, ["merge", "--squash", "squashed"]); + await git(repoDir, ["commit", "-m", "chore: squash a config change"]); + hashSquash = (await git(repoDir, ["rev-parse", "HEAD"])).trim(); + await git(repoDir, ["checkout", "-b", "feature", "-q"]); await commitFiles( repoDir, @@ -163,15 +206,32 @@ describe("semantic-release-path-filter", () => { describe("filterCommitsToPackage", () => { test("keeps only commits whose diff touches packages/config/**, preserving the input's order", async () => { - const shuffledInput = [hashCliOnly, hashBoth, hashPrefixTrap, hashMerge, hashConfigOnly].map( - (hash) => ({ - hash, - }), - ); + const shuffledInput = [ + { hash: hashCliOnly, marker: "cli" }, + { hash: hashBoth, marker: "both", metadata: { subject: "keep this object intact" } }, + { hash: hashPrefixTrap, marker: "prefix trap" }, + { hash: hashMerge, marker: "merge" }, + { hash: hashConfigOnly, marker: "config" }, + ]; const result = await filterCommitsToPackage(shuffledInput, repoDir); - expect(result).toEqual([{ hash: hashBoth }, { hash: hashConfigOnly }]); + expect(result).toEqual([shuffledInput[1], shuffledInput[4]]); + expect(result[0]).toBe(shuffledInput[1]); + expect(result[1]).toBe(shuffledInput[4]); + }); + + test.each([ + ["addition", () => hashConfigOnly], + ["modification", () => hashConfigModified], + ["deletion", () => hashConfigDeleted], + ["rename into packages/config", () => hashRenameIntoConfig], + ["rename out of packages/config", () => hashRenameOutOfConfig], + ["squash commit", () => hashSquash], + ])("selects a config %s from its actual diff", async (_scenario, getHash) => { + const hash = getHash(); + + await expect(filterCommitsToPackage([{ hash }], repoDir)).resolves.toEqual([{ hash }]); }); test("excludes a merge commit even though the branch it merged touched packages/config/**", async () => { @@ -222,6 +282,15 @@ describe("semantic-release-path-filter", () => { ).rejects.toThrow(/full lowercase hex object IDs/); }); + test.each(["not-an-object-id", "A".repeat(40), "1".repeat(39), "1".repeat(41)])( + "rejects malformed object ID %j before invoking git", + async (hash) => { + await expect(filterCommitsToPackage([{ hash }], repoDir)).rejects.toThrow( + /full lowercase hex object IDs/, + ); + }, + ); + test("rejects with a descriptive error when git diff-tree exits non-zero", async () => { const notARepo = await mkdtemp(join(tmpdir(), "semantic-release-path-filter-not-a-repo-")); try { @@ -257,6 +326,29 @@ describe("semantic-release-path-filter", () => { expect(result).toBeNull(); }); + test("excludes feat(config) when its actual diff touches only another workspace", async () => { + const context = fakeAnalyzeCommitsContext( + [fakeCommit(hashCliOnly, "feat(config): title scope does not establish ownership")], + repoDir, + ); + + await expect(analyzeCommits({}, context)).resolves.toBeNull(); + }); + + test("selects a differently scoped title when its actual diff touches packages/config", async () => { + const context = fakeAnalyzeCommitsContext( + [ + fakeCommit( + hashConfigModified, + "fix(cli): paths, rather than title scope, establish ownership", + ), + ], + repoDir, + ); + + await expect(analyzeCommits({}, context)).resolves.toBe("patch"); + }); + test('resolves "patch", not "major", because the breaking-change commit outside packages/config is filtered out', async () => { const context = fakeAnalyzeCommitsContext( [ From 37d640cb0778f5a8b9f498061ce06b7b96fda938 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Tue, 8 Sep 2026 17:50:30 +0530 Subject: [PATCH 2/4] test(release): correct historical characterization fixtures --- .../fixtures/release-history.fixtures.ts | 73 ++++++++++++++++++- ...mantic-release-history.integration.test.ts | 27 ++++++- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/apps/cli/scripts/fixtures/release-history.fixtures.ts b/apps/cli/scripts/fixtures/release-history.fixtures.ts index 6756122182..d98e1b4178 100644 --- a/apps/cli/scripts/fixtures/release-history.fixtures.ts +++ b/apps/cli/scripts/fixtures/release-history.fixtures.ts @@ -3,14 +3,21 @@ export const TRAINS = [ { train: "config", legacyPrefix: "config-v", namespace: "config@" }, ] as const; +// Each path is taken from the source commit and preserves whether that commit is +// selected for the release train. The integration harness records the generated +// commit SHA against sourceSha so it can assert the exact historical ordering. export const HISTORICAL_INTERVALS = [ { name: "CLI v2.116.0 to the first v2.117.0 beta", train: "cli", version: "2.116.0", commits: [ - { message: "feat(cli): add supabase workers new (#6261)", path: "apps/cli/workers-new.ts" }, - { message: "chore(repo): migrate live tasks to Turborepo (#6343)", path: "turbo.json" }, + { + sourceSha: "b4a91990b6ca4451a43056c8bdb45689ea79afd7", + message: "feat(cli): add supabase workers new (#6261)", + path: "apps/cli/src/legacy/cli/root.ts", + selected: true, + }, ], currentVersion: "2.117.0-beta.1", hybridVersion: "2.117.0-beta.1", @@ -21,8 +28,28 @@ export const HISTORICAL_INTERVALS = [ version: "0.1.0", commits: [ { + sourceSha: "085e5a87579d7f54f4205f87666e71b390edc4f6", + message: "chore: sync API types from infrastructure (#6428)", + path: "apps/cli-go/pkg/api/types.gen.go", + selected: false, + }, + { + sourceSha: "d345d942f26703657d2c9b0e5a497a19a9e44d3c", + message: "fix(deps): bump the go-minor group across 2 directories with 3 updates (#6429)", + path: "apps/cli-go/go.mod", + selected: false, + }, + { + sourceSha: "4fe9c9da59b6b2cfe3cfde167ae9c959a279e03d", + message: "chore(codeql): resolve deploy scan findings (#6433)", + path: "apps/cli/src/shared/functions/serve.main.ts", + selected: false, + }, + { + sourceSha: "44f463a78f6c4e15729653aeaa34063ec52627b5", message: "fix(deps): bump the npm-major group across 1 directory with 28 updates (#6430)", path: "packages/config/package.json", + selected: true, }, ], currentVersion: "0.1.1", @@ -34,12 +61,52 @@ export const HISTORICAL_INTERVALS = [ version: "0.1.1", commits: [ { + sourceSha: "430d5ede9f76590ae5f2e12a5e4a53b82eecdf5a", + message: "fix(cli): warn when PowerShell mangles piped dumps (#6418)", + path: "apps/cli/src/shared/runtime/tty.layer.ts", + selected: false, + }, + { + sourceSha: "2ce71c8de4e495b3b28729fcbffcd4c60cb6a3b0", + message: "chore: sync API types from infrastructure (#6434)", + path: "apps/cli-go/pkg/api/types.gen.go", + selected: false, + }, + { + sourceSha: "adbbe1605797eb2b9a8fd73e0d8ed972634b3edc", + message: "fix(config): align pgdelta format_options example with the 180 default (#6435)", + path: "packages/config/src/experimental.ts", + selected: true, + }, + { + sourceSha: "ed48f6667c6757bcb5267cdb4eadb6d5e32ef9da", + message: "test(cli): cover db query, lint and advisors (CLI-1949) (#6420)", + path: "apps/cli/src/legacy/commands/db/query/query.live.test.ts", + selected: false, + }, + { + sourceSha: "db1856d6c22781ced1cc8cac915b840702ef6578", + message: "test(cli): cover postgres-config get, update and delete (CLI-2271) (#6427)", + path: "apps/cli/src/legacy/commands/postgres-config/get/get.live.test.ts", + selected: false, + }, + { + sourceSha: "6b85fba64f0224de609ee109ff7b1519d2965632", message: "feat(cli): add config diff command (#6295)", path: "packages/config/src/config-diff.ts", + selected: true, + }, + { + sourceSha: "08103c023b8bef74aab01231a9df90c1d87faeaf", + message: "fix(cli): skip provisioned ledger ddl (CLI-2275) (#6422)", + path: "apps/cli/src/legacy/shared/legacy-migration-history.ts", + selected: false, }, { + sourceSha: "1b482f4de9d3680d89cb811f52813bc98ca139dd", message: "ci(config): add Slack notifications to the config release pipeline (#6436)", - path: ".github/workflows/release-config.yml", + path: "packages/config/AGENTS.md", + selected: true, }, ], currentVersion: "0.2.0", diff --git a/apps/cli/scripts/semantic-release-history.integration.test.ts b/apps/cli/scripts/semantic-release-history.integration.test.ts index 323fd2cf14..3ba32834d8 100644 --- a/apps/cli/scripts/semantic-release-history.integration.test.ts +++ b/apps/cli/scripts/semantic-release-history.integration.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import semanticRelease, { type Result } from "semantic-release"; import { afterEach, describe, expect, test } from "vitest"; +import { filterCommitsToPackage } from "../../../packages/config/scripts/semantic-release-path-filter.ts"; import { HISTORICAL_INTERVALS, TRAINS } from "./fixtures/release-history.fixtures.ts"; const CHARACTERIZATION_PLUGIN = fileURLToPath( @@ -166,7 +167,11 @@ async function runRelease(history: History, options: RunOptions): Promise { expect(result).not.toBe(false); if (result === false) { throw new Error("semantic-release unexpectedly reported no release"); @@ -394,14 +399,16 @@ test("config ownership follows paths when conventional scope disagrees", async ( }); describe.each(HISTORICAL_INTERVALS)("historical interval: $name", (fixture) => { - test("records current and hybrid results", async () => { + test("replays complete history and records current and hybrid results", async () => { const train = TRAINS.find(({ train }) => train === fixture.train); if (!train) { throw new Error(`unknown train ${fixture.train}`); } const history = await createHistory(`${train.legacyPrefix}${fixture.version}`); + const sourceShaByGeneratedSha = new Map(); for (const historicalCommit of fixture.commits) { - await commit(history, historicalCommit.message, historicalCommit.path); + const generatedSha = await commit(history, historicalCommit.message, historicalCommit.path); + sourceShaByGeneratedSha.set(generatedSha, historicalCommit.sourceSha); } await pushCurrentBranch(history); @@ -416,6 +423,20 @@ describe.each(HISTORICAL_INTERVALS)("historical interval: $name", (fixture) => { fixture.currentVersion, `${train.legacyPrefix}${fixture.currentVersion}`, ); + expect(current.commits.map(({ hash }) => sourceShaByGeneratedSha.get(hash))).toEqual( + fixture.commits.map(({ sourceSha }) => sourceSha).reverse(), + ); + + const selectedCommits = + fixture.train === "config" + ? await filterCommitsToPackage(current.commits, history.repo) + : current.commits; + expect(selectedCommits.map(({ hash }) => sourceShaByGeneratedSha.get(hash))).toEqual( + fixture.commits + .filter(({ selected }) => selected) + .map(({ sourceSha }) => sourceSha) + .reverse(), + ); const hybrid = await withCompatibilityTags( history, From 95d9a3a900838e7e22469bc25f33b0e80615f329 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Tue, 8 Sep 2026 18:05:01 +0530 Subject: [PATCH 3/4] test(release): correct bridge lifecycle characterization --- ...mantic-release-history.integration.test.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/cli/scripts/semantic-release-history.integration.test.ts b/apps/cli/scripts/semantic-release-history.integration.test.ts index 3ba32834d8..71db711efd 100644 --- a/apps/cli/scripts/semantic-release-history.integration.test.ts +++ b/apps/cli/scripts/semantic-release-history.integration.test.ts @@ -180,7 +180,7 @@ function expectRelease( expect(result.nextRelease.gitTag).toBe(gitTag); } -async function withCompatibilityTags( +async function withLocalCompatibilityTags( history: History, tags: ReadonlyArray<{ readonly name: string; readonly target: string }>, effect: () => Promise, @@ -220,12 +220,13 @@ describe.each(TRAINS)("$train namespaced release history", (train) => { : "apps/cli/release-characterization.ts"; const tagFormat = `${train.namespace}\${version}`; - test("moves from a legacy stable through two namespaced betas to a namespaced stable", async () => { + test("migrates beta and stable baselines independently from legacy tags", async () => { const history = await createHistory(`${train.legacyPrefix}1.2.3`); await commit(history, "feat: add capability", selectedPath); await pushCurrentBranch(history); - const firstBeta = await withCompatibilityTags( + // The beta bridge is eligible because develop has no namespaced release baseline yet. + const firstBeta = await withLocalCompatibilityTags( history, [{ name: `${train.namespace}1.2.3`, target: history.baselineSha }], () => runRelease(history, { train: train.train, tagFormat }), @@ -250,18 +251,30 @@ describe.each(TRAINS)("$train namespaced release history", (train) => { await git(history.repo, ["checkout", "main", "-q"]); await git(history.repo, ["merge", "--ff-only", "develop"]); await pushCurrentBranch(history); - const stable = await withCompatibilityTags( + // The stable bridge remains eligible until main has a namespaced stable tag. Reachable + // namespaced prerelease tags end the beta bridge, but they are not a stable-branch baseline. + const stable = await withLocalCompatibilityTags( history, [{ name: `${train.namespace}1.2.3`, target: history.baselineSha }], () => runRelease(history, { train: train.train, tagFormat }), ); expectRelease(stable, "1.3.0", `${train.namespace}1.3.0`); + await tagRelease(history, `${train.namespace}1.3.0`, "latest"); + await commit(history, "fix: correct stable behavior", selectedPath); + await pushCurrentBranch(history); + const subsequentStable = await runRelease(history, { train: train.train, tagFormat }); + expectRelease(subsequentStable, "1.3.1", `${train.namespace}1.3.1`); + expect(await git(history.repo, ["rev-parse", `refs/tags/${history.legacyTag}`])).toBe( history.baselineSha, ); expect((await git(history.repo, ["tag", "--list", `${train.namespace}*`])).split("\n")).toEqual( - [`${train.namespace}1.3.0-beta.1`, `${train.namespace}1.3.0-beta.2`], + [ + `${train.namespace}1.3.0`, + `${train.namespace}1.3.0-beta.1`, + `${train.namespace}1.3.0-beta.2`, + ], ); }, 30_000); @@ -272,7 +285,7 @@ describe.each(TRAINS)("$train namespaced release history", (train) => { await commit(history, "fix: continue beta testing", selectedPath); await pushCurrentBranch(history); - const result = await withCompatibilityTags( + const result = await withLocalCompatibilityTags( history, [ { name: `${train.namespace}1.2.3`, target: history.baselineSha }, @@ -290,7 +303,7 @@ describe.each(TRAINS)("$train namespaced release history", (train) => { await commit(history, "fix: reviewed production hotfix", selectedPath); await pushCurrentBranch(history); - const result = await withCompatibilityTags( + const result = await withLocalCompatibilityTags( history, [{ name: `${train.namespace}1.2.3`, target: history.baselineSha }], () => runRelease(history, { train: train.train, tagFormat }), @@ -308,7 +321,7 @@ describe.each(TRAINS)("$train namespaced release history", (train) => { const history = await createHistory(`${train.legacyPrefix}1.2.3`); await commit(history, scenario.message, selectedPath); await pushCurrentBranch(history); - const result = await withCompatibilityTags( + const result = await withLocalCompatibilityTags( history, [{ name: `${train.namespace}1.2.3`, target: history.baselineSha }], () => runRelease(history, { train: train.train, tagFormat }), @@ -438,7 +451,7 @@ describe.each(HISTORICAL_INTERVALS)("historical interval: $name", (fixture) => { .reverse(), ); - const hybrid = await withCompatibilityTags( + const hybrid = await withLocalCompatibilityTags( history, [{ name: `${train.namespace}${fixture.version}`, target: history.baselineSha }], () => @@ -468,7 +481,7 @@ test("the config difference from stock analysis is limited to title-only classif }); expectRelease(current, "2.0.0", "config-v2.0.0"); - const hybrid = await withCompatibilityTags( + const hybrid = await withLocalCompatibilityTags( history, [{ name: "config@1.2.3", target: history.baselineSha }], () => runRelease(history, { train: "config", tagFormat: "config@${version}" }), From c04601f3c684bfd5c59fbaa8d1651b058261cf9e Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Tue, 8 Sep 2026 19:19:17 +0530 Subject: [PATCH 4/4] test(release): make history fixtures independent of git defaults --- apps/cli/scripts/semantic-release-history.integration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/scripts/semantic-release-history.integration.test.ts b/apps/cli/scripts/semantic-release-history.integration.test.ts index 71db711efd..6ceeba802b 100644 --- a/apps/cli/scripts/semantic-release-history.integration.test.ts +++ b/apps/cli/scripts/semantic-release-history.integration.test.ts @@ -88,6 +88,7 @@ async function createHistory(legacyTag: string): Promise { await git(repo, ["tag", legacyTag]); await git(repo, ["remote", "add", "origin", pathToFileURL(origin).href]); await git(repo, ["push", "-u", "origin", "main", "--tags"]); + await git(origin, ["symbolic-ref", "HEAD", "refs/heads/main"]); await git(repo, ["checkout", "-b", "develop", "-q"]); await git(repo, ["push", "-u", "origin", "develop"]); return { root, repo, origin, legacyTag, baselineSha, nextFile: 0 };