Skip to content

Commit 529b93f

Browse files
ci: skip npm publish when only CI, tests, or scripts change (#9)
Match workit's path-gated releases: semantic-release cuts a version only if src, plugin, or catalog files changed since the last tag. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 855fb85 commit 529b93f

7 files changed

Lines changed: 243 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- npm publishes only when plugin or catalog files change since the last tag — CI, tests, and scripts-only merges skip a release.
13+
1014
## [0.6.1] - 2026-08-28
1115

1216
### Added

bun.lock

Lines changed: 30 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"catalog:ci": "bun run scripts/catalog-sync-ci.ts"
7070
},
7171
"devDependencies": {
72-
"@semantic-release/commit-analyzer": "13.0.1",
72+
"@semantic-release/exec": "7.1.0",
7373
"@semantic-release/github": "12.0.9",
7474
"@semantic-release/npm": "13.1.5",
7575
"@semantic-release/release-notes-generator": "14.1.1",

release.config.cjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
module.exports = {
22
branches: ["main"],
33
plugins: [
4-
"@semantic-release/commit-analyzer",
4+
// Path-gated: prints major|minor|patch only when src/plugin/catalog files
5+
// changed since the previous v* tag. Empty output skips npm + GitHub.
6+
[
7+
"@semantic-release/exec",
8+
{
9+
analyzeCommitsCmd: "bun scripts/analyze-release-scope.ts",
10+
},
11+
],
512
"./scripts/semantic-release-catalog-notes.cjs",
613
"@semantic-release/release-notes-generator",
714
"./scripts/semantic-release-changelog.cjs",

scripts/analyze-release-scope.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bun
2+
// Path-gated releases (workit AR-16): a releasable commit counts only when it
3+
// touches package payload. CI/docs/tests/scripts-only merges cut no npm publish.
4+
import { execFileSync } from "node:child_process";
5+
import { resolve } from "node:path";
6+
7+
const PRODUCT_FILES = new Set([
8+
"plugin.ts",
9+
"index.ts",
10+
"models.json",
11+
"manifest.json",
12+
"_version.txt",
13+
]);
14+
15+
export function isProductPath(file: string): boolean {
16+
return PRODUCT_FILES.has(file) || file.startsWith("src/");
17+
}
18+
19+
const g = (root: string, args: string[]): string =>
20+
execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
21+
22+
const SEMVER_TAG = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
23+
24+
export function latestTag(root = process.cwd()): string | null {
25+
const out = g(root, ["tag", "--list", "v*", "--sort=-v:refname"])
26+
.split("\n")
27+
.map((l) => l.trim())
28+
.filter((l) => SEMVER_TAG.test(l));
29+
return out[0] ?? null;
30+
}
31+
32+
type Level = "major" | "minor" | "patch";
33+
const LEVEL_RANK: Record<Level, number> = { patch: 1, minor: 2, major: 3 };
34+
const TYPE_LEVEL: Record<string, Level> = { fix: "patch", perf: "patch", feat: "minor" };
35+
36+
const subjectLevel = (commit: string): Level | null => {
37+
const firstLine = commit.split("\n")[0] ?? "";
38+
const m = /^(?:fix|perf|feat)(?:\([^)]*\))?!?:/.exec(firstLine);
39+
if (!m) return null;
40+
if (m[0].includes("!")) return "major";
41+
const body = commit.split("\n").slice(1).join("\n");
42+
const type = m[0].replace(/\(.*$/, "").replace(/!$/, "").replace(/:$/, "");
43+
return /BREAKING[- ]CHANGE:/.test(body) ? "major" : (TYPE_LEVEL[type] ?? null);
44+
};
45+
46+
const commitsSince = (root: string, from: string): { message: string; files: string[] }[] => {
47+
const hashes = g(root, ["log", "--reverse", "--format=%H", `${from}..HEAD`])
48+
.split("\n")
49+
.filter(Boolean);
50+
return hashes.map((h) => ({
51+
message: g(root, ["show", "-s", "--format=%B", h]),
52+
files: g(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-m", "--root", "-z", h])
53+
.split("\0")
54+
.filter(Boolean),
55+
}));
56+
};
57+
58+
export function analyzeReleaseScope(root = process.cwd()): { level: Level | null } {
59+
const from = latestTag(root);
60+
if (from === null) return { level: "minor" };
61+
const levels: Level[] = [];
62+
for (const { message, files } of commitsSince(root, from)) {
63+
if (!files.some(isProductPath)) continue;
64+
const lvl = subjectLevel(message);
65+
if (lvl) levels.push(lvl);
66+
}
67+
if (levels.length === 0) return { level: null };
68+
const level = levels.reduce<Level>(
69+
(best, l) => (LEVEL_RANK[l] > LEVEL_RANK[best] ? l : best),
70+
"patch",
71+
);
72+
return { level };
73+
}
74+
75+
if (import.meta.main) {
76+
const root = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
77+
const { level } = analyzeReleaseScope(root);
78+
if (level) process.stdout.write(`${level}\n`);
79+
}

0 commit comments

Comments
 (0)