Skip to content

Commit f0316ba

Browse files
committed
feat(release): skip publishes for test-only changes
1 parent 1077077 commit f0316ba

5 files changed

Lines changed: 261 additions & 190 deletions

File tree

.github/scripts/version.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
applyBump,
1212
calculateVersion,
1313
findReleaseAs,
14+
hasNonTestChanges,
1415
parseSemver,
1516
} from "./version.ts";
1617

@@ -283,13 +284,40 @@ describe("parseSemver", () => {
283284
});
284285
});
285286

287+
describe("hasNonTestChanges", () => {
288+
it("returns false for only test files", () => {
289+
assertEquals(
290+
hasNonTestChanges([
291+
".github/scripts/version.test.ts",
292+
"src/foo.test.ts",
293+
]),
294+
false,
295+
);
296+
});
297+
298+
it("returns true when at least one non-test file is present", () => {
299+
assertEquals(
300+
hasNonTestChanges([
301+
".github/scripts/version.test.ts",
302+
".github/scripts/version.ts",
303+
]),
304+
true,
305+
);
306+
});
307+
308+
it("returns false for an empty file list", () => {
309+
assertEquals(hasNonTestChanges([]), false);
310+
});
311+
});
312+
286313
describe("calculateVersion", () => {
287314
const baseOpts = {
288315
currentVersion: "1.0.0",
289316
subjects: [],
290317
bodies: [],
291318
commitSha: "abc123def456",
292319
timestamp: "20260212091429",
320+
changedFiles: ["src/mod.ts"],
293321
noGitTags: false,
294322
};
295323

@@ -349,6 +377,26 @@ describe("calculateVersion", () => {
349377
});
350378
assertEquals(result, { skip: false, version: "0.6.0", tag: "latest" });
351379
});
380+
381+
it("skips when unreleased changes are only test files", () => {
382+
const result = calculateVersion({
383+
...baseOpts,
384+
subjects: ["feat: new feature"],
385+
changedFiles: ["src/foo.test.ts", ".github/scripts/version.test.ts"],
386+
eventName: "push",
387+
});
388+
assertEquals(result, { skip: true });
389+
});
390+
391+
it("still releases when test and non-test files both changed", () => {
392+
const result = calculateVersion({
393+
...baseOpts,
394+
subjects: ["feat: new feature"],
395+
changedFiles: ["src/foo.test.ts", "src/foo.ts"],
396+
eventName: "push",
397+
});
398+
assertEquals(result, { skip: false, version: "1.1.0", tag: "latest" });
399+
});
352400
});
353401

354402
describe("pull_request events (canaries)", () => {
@@ -417,6 +465,16 @@ describe("calculateVersion", () => {
417465
assertEquals(result.version.includes("1234567."), true);
418466
}
419467
});
468+
469+
it("skips canary publish when unreleased changes are only test files", () => {
470+
const result = calculateVersion({
471+
...baseOpts,
472+
subjects: ["feat: feature"],
473+
changedFiles: ["src/foo.test.ts"],
474+
eventName: "pull_request",
475+
});
476+
assertEquals(result, { skip: true });
477+
});
420478
});
421479

422480
describe("noGitTags fallback", () => {

.github/scripts/version.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ export function parseSemver(
106106
];
107107
}
108108

109+
/** Whether the changed paths include at least one non-test file. */
110+
export function hasNonTestChanges(changedFiles: string[]): boolean {
111+
return changedFiles.some((file) => file && !file.endsWith(".test.ts"));
112+
}
113+
109114
/**
110115
* Calculate the next version given all inputs.
111116
*
@@ -124,9 +129,15 @@ export function calculateVersion(opts: {
124129
commitSha: string;
125130
/** Timestamp string for canary suffix (e.g. "20260212091429"). */
126131
timestamp: string;
132+
/** Files changed since the last release baseline. */
133+
changedFiles: string[];
127134
/** Whether we fell back to npm (no git tags). */
128135
noGitTags: boolean;
129136
}): VersionResult {
137+
if (!hasNonTestChanges(opts.changedFiles)) {
138+
return { skip: true };
139+
}
140+
130141
const [major, minor, patch] = parseSemver(opts.currentVersion);
131142

132143
// Check for Release-As override first
@@ -229,6 +240,7 @@ async function run(args: string[]): Promise<void> {
229240
let currentVersion: string;
230241
let subjects: string[];
231242
let bodies: string[];
243+
let changedFiles: string[];
232244
let noGitTags: boolean;
233245

234246
if (!latestTag) {
@@ -238,6 +250,8 @@ async function run(args: string[]): Promise<void> {
238250
currentVersion = npmVersion || "0.0.0";
239251
subjects = (await cmd("git", "log", "--format=%s")).split("\n");
240252
bodies = (await cmd("git", "log", "--format=%b")).split("\n");
253+
changedFiles = (await cmd("git", "ls-tree", "-r", "--name-only", "HEAD"))
254+
.split("\n");
241255
noGitTags = true;
242256
} else {
243257
currentVersion = latestTag.replace(/^v/, "");
@@ -253,6 +267,12 @@ async function run(args: string[]): Promise<void> {
253267
`${latestTag}..HEAD`,
254268
"--format=%b",
255269
)).split("\n");
270+
changedFiles = (await cmd(
271+
"git",
272+
"diff",
273+
"--name-only",
274+
`${latestTag}..HEAD`,
275+
)).split("\n");
256276
noGitTags = false;
257277
}
258278

@@ -265,6 +285,7 @@ async function run(args: string[]): Promise<void> {
265285
eventName,
266286
commitSha,
267287
timestamp,
288+
changedFiles,
268289
noGitTags,
269290
});
270291

src/config.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { assertFalse, assertStrictEquals } from "jsr:@std/assert@^1.0.0";
22
import { describe, it } from "jsr:@std/testing@^1.0.0/bdd";
3+
import { stub } from "jsr:@std/testing@^1.0.0/mock";
4+
import os from "node:os";
35
import { join } from "node:path";
46
import { loadConfig } from "./config.ts";
57
import type { GraphitiConfig } from "./types/index.ts";
@@ -215,5 +217,106 @@ describe("config", () => {
215217
});
216218
});
217219
});
220+
221+
// --- legacy home fallback tests ---
222+
//
223+
// README documents a legacy fallback at ~/.config/opencode/.graphitirc (step 3 in
224+
// lookup order). These tests are deterministic regression tests that verify this
225+
// exact path is read when no project-local or standard global config is found.
226+
//
227+
// The tests use an isolated fake home directory (via os.homedir() stub) so the
228+
// real home directory is never touched.
229+
230+
it("loads ~/.config/opencode/.graphitirc as legacy fallback when no other config exists", async () => {
231+
// Create an isolated fake home dir so the real ~ is never touched.
232+
const fakeHome = await Deno.makeTempDir({ prefix: "graphiti_fakehome_" });
233+
const legacyDir = join(fakeHome, ".config", "opencode");
234+
235+
try {
236+
await Deno.mkdir(legacyDir, { recursive: true });
237+
await Deno.writeTextFile(
238+
join(legacyDir, ".graphitirc"),
239+
JSON.stringify({
240+
endpoint: "http://legacy-opencode.local/mcp",
241+
driftThreshold: 0.8,
242+
factStaleDays: 42,
243+
}),
244+
);
245+
246+
// Redirect os.homedir() for the duration of this test only.
247+
// CWD is outside fakeHome so the upward walk never reaches it.
248+
using _homedirStub = stub(os, "homedir", () => fakeHome);
249+
250+
await withTempDirAsCwd(() => {
251+
const config = loadConfig();
252+
assertConfigValues(config, {
253+
endpoint: "http://legacy-opencode.local/mcp",
254+
groupIdPrefix: "opencode", // default, not in file
255+
driftThreshold: 0.8,
256+
factStaleDays: 42,
257+
});
258+
});
259+
} finally {
260+
await Deno.remove(fakeHome, { recursive: true });
261+
}
262+
});
263+
264+
it("merges partial ~/.config/opencode/.graphitirc with defaults", async () => {
265+
const fakeHome = await Deno.makeTempDir({ prefix: "graphiti_fakehome_" });
266+
const legacyDir = join(fakeHome, ".config", "opencode");
267+
268+
try {
269+
await Deno.mkdir(legacyDir, { recursive: true });
270+
// Only override one field; remaining fields must come from DEFAULT_CONFIG.
271+
await Deno.writeTextFile(
272+
join(legacyDir, ".graphitirc"),
273+
JSON.stringify({ endpoint: "http://partial-legacy.local/mcp" }),
274+
);
275+
276+
using _homedirStub = stub(os, "homedir", () => fakeHome);
277+
278+
await withTempDirAsCwd(() => {
279+
const config = loadConfig();
280+
assertStrictEquals(
281+
config.endpoint,
282+
"http://partial-legacy.local/mcp",
283+
);
284+
assertStrictEquals(config.groupIdPrefix, "opencode"); // default
285+
assertStrictEquals(config.driftThreshold, 0.5); // default
286+
assertStrictEquals(config.factStaleDays, 30); // default
287+
});
288+
} finally {
289+
await Deno.remove(fakeHome, { recursive: true });
290+
}
291+
});
292+
293+
it("project-local config takes precedence over ~/.config/opencode/.graphitirc", async () => {
294+
const fakeHome = await Deno.makeTempDir({ prefix: "graphiti_fakehome_" });
295+
const legacyDir = join(fakeHome, ".config", "opencode");
296+
297+
try {
298+
await Deno.mkdir(legacyDir, { recursive: true });
299+
await Deno.writeTextFile(
300+
join(legacyDir, ".graphitirc"),
301+
JSON.stringify({
302+
endpoint: "http://legacy-should-be-ignored.local/mcp",
303+
}),
304+
);
305+
306+
using _homedirStub = stub(os, "homedir", () => fakeHome);
307+
308+
await withTempDir(async (projectDir) => {
309+
await Deno.writeTextFile(
310+
join(projectDir, ".graphitirc"),
311+
JSON.stringify({ endpoint: "http://project-wins.local/mcp" }),
312+
);
313+
314+
const config = loadConfig(projectDir);
315+
assertStrictEquals(config.endpoint, "http://project-wins.local/mcp");
316+
});
317+
} finally {
318+
await Deno.remove(fakeHome, { recursive: true });
319+
}
320+
});
218321
});
219322
});

src/config.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,34 @@ const GraphitiConfigSchema = z.object({
2020
/**
2121
* Load Graphiti configuration from JSONC files with defaults applied.
2222
*
23-
* When `directory` is provided, the search starts from that directory (no
24-
* upward traversal past it) so that a project-local `.graphitirc` or
25-
* `package.json#graphiti` key takes precedence over any global/home config.
26-
* If no config is found in the project directory the search falls back to a
27-
* global search (home directory and OS-level config locations).
23+
* Lookup order:
24+
* 1. `directory` (if provided): standard cosmiconfig search starting from that
25+
* directory (no upward traversal past it) — project-local `.graphitirc`,
26+
* `package.json#graphiti`, etc.
27+
* 2. Standard global/home cosmiconfig locations discovered by walking upward
28+
* from CWD to the home directory (e.g. `~/.graphitirc`).
29+
* 3. Legacy fallback: `~/.config/opencode/.graphitirc` — the path used by
30+
* earlier versions of the plugin.
2831
*/
2932
export function loadConfig(directory?: string): GraphitiConfig {
30-
const result = cosmiconfigSync("graphiti", {
33+
const explorer = cosmiconfigSync("graphiti", {
3134
stopDir: os.homedir(),
3235
mergeSearchPlaces: true,
3336
cache: false,
34-
}).search(directory) ??
35-
cosmiconfigSync("graphiti", {
36-
searchPlaces: [`${os.homedir()}/.graphitirc`],
37-
}).search();
37+
});
38+
39+
// Step 1 & 2: project-local search (with directory arg) or CWD upward walk.
40+
const result = explorer.search(directory) ??
41+
(() => {
42+
// Step 3: legacy fallback — load the fixed path explicitly so that
43+
// cosmiconfig's search-place joining does not mangle absolute paths.
44+
const legacyPath = `${os.homedir()}/.config/opencode/.graphitirc`;
45+
try {
46+
return cosmiconfigSync("graphiti", { cache: false }).load(legacyPath);
47+
} catch {
48+
return null;
49+
}
50+
})();
3851

3952
const merged = {
4053
...DEFAULT_CONFIG,

0 commit comments

Comments
 (0)