Skip to content

Commit 3de339d

Browse files
authored
Merge pull request #176 from linearis-oss/fix/calver-release-pipeline
fix(release): prevent calver prerelease jumps and fail on invalid semantic drift
2 parents dfd7b16 + d3ab35c commit 3de339d

5 files changed

Lines changed: 217 additions & 41 deletions

File tree

.releaserc.cjs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ module.exports = {
66
tagFormat: `v\${version}`,
77
plugins: [
88
[
9-
"@semantic-release/commit-analyzer",
9+
"./scripts/release/calver-plugin.cjs",
1010
{
1111
preset: "conventionalcommits",
1212
releaseRules: [
13-
// Calver plugin controls final version string.
14-
// Custom rules here only suppress non-deliverable commits.
15-
// Releasable commits (feat/fix/perf/revert/breaking) follow default analyzer rules.
13+
// Suppress non-deliverable commits.
14+
// Releasable commits (feat/fix/perf/revert/breaking) still trigger release,
15+
// then calver-plugin maps analyzer output to patch cadence.
1616
{ type: "refactor", release: false },
1717
{ type: "chore", release: false },
1818
{ type: "ci", release: false },
@@ -30,7 +30,6 @@ module.exports = {
3030
"@semantic-release/release-notes-generator",
3131
{ preset: "conventionalcommits" },
3232
],
33-
"./scripts/release/calver-plugin.cjs",
3433
["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
3534
["@semantic-release/npm", { npmPublish: false, pkgRoot: "." }],
3635
[

scripts/release/calver-plugin.cjs

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,81 @@
1-
const { computeCalverVersion } = require("./calver.cjs");
1+
const commitAnalyzer = require("@semantic-release/commit-analyzer");
2+
const { computeCalverVersion, isMonthRollover } = require("./calver.cjs");
23

3-
module.exports = {
4-
verifyRelease: async (_, context) => {
5-
const lastVersion = context.lastRelease?.version ?? "1970.1.0";
6-
const branchName = context.branch?.name ?? "main";
4+
function mapCalverReleaseType({
5+
branchName,
6+
releaseType,
7+
lastVersion,
8+
nowIso,
9+
}) {
10+
if (!releaseType) {
11+
return null;
12+
}
13+
14+
if (branchName !== "main" && branchName !== "next") {
15+
return releaseType;
16+
}
17+
18+
if (isMonthRollover({ lastVersion, branchName, nowIso })) {
19+
return "minor";
20+
}
21+
22+
return "patch";
23+
}
724

8-
const nextVersion = computeCalverVersion({
9-
lastVersion,
10-
branchName,
11-
nowIso: new Date().toISOString(),
12-
});
25+
async function analyzeCommits(pluginConfig, context) {
26+
const releaseType = await commitAnalyzer.analyzeCommits(
27+
pluginConfig,
28+
context,
29+
);
1330

31+
const branchName = context.branch?.name ?? "main";
32+
const lastVersion = context.lastRelease?.version ?? "1970.1.0";
33+
const nowIso = new Date().toISOString();
34+
35+
const mappedReleaseType = mapCalverReleaseType({
36+
branchName,
37+
releaseType,
38+
lastVersion,
39+
nowIso,
40+
});
41+
42+
if (releaseType !== mappedReleaseType) {
1443
context.logger.log(
15-
`calver-plugin: forcing next release version to ${nextVersion}`,
44+
`calver-plugin: mapped release type ${releaseType} -> ${mappedReleaseType} on ${branchName}`,
1645
);
17-
context.nextRelease.version = nextVersion;
18-
},
46+
}
47+
48+
return mappedReleaseType;
49+
}
50+
51+
async function verifyRelease(_, context) {
52+
const lastVersion = context.lastRelease?.version ?? "1970.1.0";
53+
const branchName = context.branch?.name ?? "main";
54+
const semanticVersion = context.nextRelease?.version;
55+
56+
if (!semanticVersion) {
57+
throw new Error("calver-plugin: missing context.nextRelease.version");
58+
}
59+
60+
const expectedVersion = computeCalverVersion({
61+
lastVersion,
62+
branchName,
63+
nowIso: new Date().toISOString(),
64+
});
65+
66+
if (semanticVersion !== expectedVersion) {
67+
throw new Error(
68+
`calver-plugin: semantic-release computed ${semanticVersion} but calver requires ${expectedVersion}`,
69+
);
70+
}
71+
72+
context.logger.log(
73+
`calver-plugin: verified semantic-release version ${semanticVersion}`,
74+
);
75+
}
76+
77+
module.exports = {
78+
analyzeCommits,
79+
mapCalverReleaseType,
80+
verifyRelease,
1981
};

scripts/release/calver.cjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ function parseLastVersion(lastVersion, branchName) {
3636
};
3737
}
3838

39+
function isMonthRollover({ lastVersion, branchName, nowIso }) {
40+
const now = getUtcYearMonth(nowIso);
41+
const last = parseLastVersion(lastVersion, branchName);
42+
43+
return !(last.year === now.year && last.month === now.month);
44+
}
45+
3946
function computeCalverVersion({ lastVersion, branchName, nowIso }) {
4047
const now = getUtcYearMonth(nowIso);
4148
const last = parseLastVersion(lastVersion, branchName);
@@ -44,18 +51,19 @@ function computeCalverVersion({ lastVersion, branchName, nowIso }) {
4451

4552
if (branchName === "next") {
4653
if (!sameMonth) {
47-
return `${now.year}.${now.month}.1-next.1`;
54+
return `${now.year}.${now.month}.0-next.1`;
4855
}
4956

5057
const patch = last.fromPrerelease ? last.patch : last.patch + 1;
5158
const counter = last.fromPrerelease ? last.nextCounter + 1 : 1;
5259
return `${now.year}.${now.month}.${patch}-next.${counter}`;
5360
}
5461

55-
const patch = sameMonth ? last.patch + 1 : 1;
62+
const patch = sameMonth ? last.patch + 1 : 0;
5663
return `${now.year}.${now.month}.${patch}`;
5764
}
5865

5966
module.exports = {
6067
computeCalverVersion,
68+
isMonthRollover,
6169
};

tests/unit/release/calver-plugin.test.ts

Lines changed: 125 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,153 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { verifyRelease } from "../../../scripts/release/calver-plugin.cjs";
2+
import {
3+
analyzeCommits,
4+
mapCalverReleaseType,
5+
verifyRelease,
6+
} from "../../../scripts/release/calver-plugin.cjs";
37

48
describe("calver plugin", () => {
5-
it("sets nextRelease.version in verifyRelease for main", async () => {
6-
expect(verifyRelease).toBeTypeOf("function");
9+
it("maps releasable commits to patch within same month", () => {
10+
expect(
11+
mapCalverReleaseType({
12+
branchName: "main",
13+
releaseType: "minor",
14+
lastVersion: "2026.4.8",
15+
nowIso: "2026-04-20T10:00:00.000Z",
16+
}),
17+
).toBe("patch");
718

19+
expect(
20+
mapCalverReleaseType({
21+
branchName: "next",
22+
releaseType: "major",
23+
lastVersion: "2026.4.8-next.6",
24+
nowIso: "2026-04-20T10:00:00.000Z",
25+
}),
26+
).toBe("patch");
27+
});
28+
29+
it("maps releasable commits to minor on month rollover", () => {
30+
expect(
31+
mapCalverReleaseType({
32+
branchName: "main",
33+
releaseType: "patch",
34+
lastVersion: "2026.4.8",
35+
nowIso: "2026-05-01T00:00:00.000Z",
36+
}),
37+
).toBe("minor");
38+
39+
expect(
40+
mapCalverReleaseType({
41+
branchName: "next",
42+
releaseType: "patch",
43+
lastVersion: "2026.4.8-next.6",
44+
nowIso: "2026-05-01T00:00:00.000Z",
45+
}),
46+
).toBe("minor");
47+
});
48+
49+
it("preserves null release type", () => {
50+
expect(
51+
mapCalverReleaseType({
52+
branchName: "main",
53+
releaseType: null,
54+
lastVersion: "2026.4.8",
55+
nowIso: "2026-04-20T10:00:00.000Z",
56+
}),
57+
).toBeNull();
58+
});
59+
60+
it("delegates commit analysis and normalizes to patch cadence", async () => {
61+
const pluginConfig = {
62+
preset: "conventionalcommits",
63+
releaseRules: [{ type: "chore", release: false }],
64+
};
65+
66+
const context = {
67+
branch: { name: "next" },
68+
commits: [{ message: "feat(labels): add project labels" }],
69+
cwd: process.cwd(),
70+
env: process.env,
71+
logger: { log: vi.fn<(message: string) => void>() },
72+
options: {},
73+
lastRelease: { version: "2026.4.8-next.6" },
74+
};
75+
76+
await expect(analyzeCommits(pluginConfig, context)).resolves.toBe("patch");
77+
});
78+
79+
it("rolls over to minor on month change during analyzeCommits", async () => {
880
vi.useFakeTimers();
9-
vi.setSystemTime(new Date("2026-04-20T10:00:00.000Z"));
81+
vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z"));
82+
83+
const pluginConfig = {
84+
preset: "conventionalcommits",
85+
releaseRules: [{ type: "chore", release: false }],
86+
};
1087

1188
const context = {
12-
lastRelease: { version: "2026.4.5" },
13-
branch: { name: "main" },
89+
branch: { name: "next" },
90+
commits: [{ message: "fix(labels): repair filters" }],
91+
cwd: process.cwd(),
92+
env: process.env,
1493
logger: { log: vi.fn<(message: string) => void>() },
15-
nextRelease: { version: "0.0.0" },
94+
options: {},
95+
lastRelease: { version: "2026.4.8-next.6" },
1696
};
1797

18-
await verifyRelease({}, context);
98+
await expect(analyzeCommits(pluginConfig, context)).resolves.toBe("minor");
1999

20-
expect(context.nextRelease.version).toBe("2026.4.6");
21-
expect(context.logger.log).toHaveBeenCalledWith(
22-
"calver-plugin: forcing next release version to 2026.4.6",
100+
vi.useRealTimers();
101+
});
102+
103+
it("fails when semantic-release next version diverges from calver", async () => {
104+
vi.useFakeTimers();
105+
vi.setSystemTime(new Date("2026-04-20T10:00:00.000Z"));
106+
107+
const context = {
108+
lastRelease: { version: "2026.4.8-next.6" },
109+
branch: { name: "next" },
110+
logger: { log: vi.fn<(message: string) => void>() },
111+
nextRelease: { version: "2026.5.0-next.1" },
112+
};
113+
114+
await expect(verifyRelease({}, context)).rejects.toThrow(
115+
"semantic-release computed 2026.5.0-next.1 but calver requires 2026.4.8-next.7",
23116
);
24117

25118
vi.useRealTimers();
26119
});
27120

28-
it("increments prerelease counter on next branch", async () => {
121+
it("passes month-rollover semantic-release version", async () => {
122+
vi.useFakeTimers();
123+
vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z"));
124+
125+
const context = {
126+
lastRelease: { version: "2026.4.8" },
127+
branch: { name: "main" },
128+
logger: { log: vi.fn<(message: string) => void>() },
129+
nextRelease: { version: "2026.5.0" },
130+
};
131+
132+
await expect(verifyRelease({}, context)).resolves.toBeUndefined();
133+
134+
vi.useRealTimers();
135+
});
136+
137+
it("passes when semantic-release next version matches calver", async () => {
29138
vi.useFakeTimers();
30139
vi.setSystemTime(new Date("2026-04-20T10:00:00.000Z"));
31140

32141
const context = {
33-
lastRelease: { version: "2026.4.6-next.2" },
142+
lastRelease: { version: "2026.4.8-next.6" },
34143
branch: { name: "next" },
35144
logger: { log: vi.fn<(message: string) => void>() },
36-
nextRelease: { version: "0.0.0" },
145+
nextRelease: { version: "2026.4.8-next.7" },
37146
};
38147

39-
await verifyRelease({}, context);
40-
41-
expect(context.nextRelease.version).toBe("2026.4.6-next.3");
148+
await expect(verifyRelease({}, context)).resolves.toBeUndefined();
42149
expect(context.logger.log).toHaveBeenCalledWith(
43-
"calver-plugin: forcing next release version to 2026.4.6-next.3",
150+
"calver-plugin: verified semantic-release version 2026.4.8-next.7",
44151
);
45152

46153
vi.useRealTimers();

tests/unit/release/calver.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ describe("computeCalverVersion", () => {
1212
expect(next).toBe("2026.4.6");
1313
});
1414

15-
it("resets patch to 1 when UTC month rolls", () => {
15+
it("resets patch to 0 when UTC month rolls", () => {
1616
const next = computeCalverVersion({
1717
lastVersion: "2026.4.5",
1818
branchName: "main",
1919
nowIso: "2026-05-01T00:00:00.000Z",
2020
});
2121

22-
expect(next).toBe("2026.5.1");
22+
expect(next).toBe("2026.5.0");
2323
});
2424

2525
it("creates first prerelease from stable version on next branch", () => {
@@ -49,7 +49,7 @@ describe("computeCalverVersion", () => {
4949
nowIso: "2026-05-01T00:00:00.000Z",
5050
});
5151

52-
expect(next).toBe("2026.5.1-next.1");
52+
expect(next).toBe("2026.5.0-next.1");
5353
});
5454

5555
it("throws for invalid lastVersion", () => {

0 commit comments

Comments
 (0)