Skip to content

Commit 0721060

Browse files
committed
refactor: use package.json versions as source of truth for releases
1 parent ea6fe33 commit 0721060

6 files changed

Lines changed: 139 additions & 35 deletions

File tree

packages/tools/src/cli/commands/release.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const releaseCommand = buildCommand<ReleaseCommandFlags, ReleaseArgs>({
7171
docs: {
7272
brief: 'Release backend, frontend, and standalone packages',
7373
fullDescription:
74-
'Bump release versions from the latest v* tag and create a commit and signed tag.',
74+
'Bump release versions from current package versions and create a commit and signed tag.',
7575
},
7676
func: runReleaseCommand,
7777
});

packages/tools/src/lib/release.mock.test.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
33
const getRepoRoot = vi.fn();
44
const getWorktreeStatus = vi.fn();
55
const hasTag = vi.fn();
6-
const listVersionTags = vi.fn();
76
const stageFiles = vi.fn();
87
const createCommit = vi.fn();
98
const createTag = vi.fn();
@@ -16,7 +15,6 @@ vi.mock('./git.ts', () => ({
1615
getRepoRoot,
1716
getWorktreeStatus,
1817
hasTag,
19-
listVersionTags,
2018
stageFiles,
2119
}));
2220

@@ -30,7 +28,6 @@ describe('performRelease mocked safety checks', () => {
3028
getRepoRoot.mockReset();
3129
getWorktreeStatus.mockReset();
3230
hasTag.mockReset();
33-
listVersionTags.mockReset();
3431
stageFiles.mockReset();
3532
createCommit.mockReset();
3633
createTag.mockReset();
@@ -39,9 +36,8 @@ describe('performRelease mocked safety checks', () => {
3936

4037
getRepoRoot.mockResolvedValue('/repo');
4138
getWorktreeStatus.mockResolvedValue('');
42-
listVersionTags.mockResolvedValue(['v0.0.3']);
4339
hasTag.mockResolvedValue(false);
44-
readPackageVersion.mockResolvedValue('1.0.0');
40+
readPackageVersion.mockResolvedValue('0.0.3');
4541
});
4642

4743
test('fails when the computed next tag already exists', async () => {
@@ -89,4 +85,30 @@ describe('performRelease mocked safety checks', () => {
8985
},
9086
);
9187
});
88+
89+
test('bumps from the current package version when tags lag behind', async () => {
90+
const { performRelease } = await import('./release.ts');
91+
92+
readPackageVersion.mockResolvedValue('0.0.4');
93+
94+
await performRelease({
95+
cwd: '/repo',
96+
dryRun: false,
97+
logger: {
98+
info: () => {},
99+
start: () => {},
100+
},
101+
releaseType: 'patch',
102+
});
103+
104+
expect(createCommit).toHaveBeenCalledWith('/repo', 'chore: release v0.0.5');
105+
expect(createTag).toHaveBeenCalledWith(
106+
'/repo',
107+
'v0.0.5',
108+
'release: v0.0.5',
109+
{
110+
sign: true,
111+
},
112+
);
113+
});
92114
});

packages/tools/src/lib/release.test.ts

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ describe('performRelease', () => {
108108

109109
await expect(
110110
readVersion(repoRoot, 'packages/server/package.json'),
111-
).resolves.toBe('1.0.0');
111+
).resolves.toBe('0.0.3');
112112
await expect(
113113
readVersion(repoRoot, 'packages/frontend/package.json'),
114114
).resolves.toBe('2.0.0');
@@ -118,6 +118,42 @@ describe('performRelease', () => {
118118
);
119119
});
120120

121+
test('bumps from current package versions even when latest tag lags behind', async () => {
122+
await writePackageJson(repoRoot, 'packages/server/package.json', '0.0.4');
123+
await writePackageJson(repoRoot, 'packages/frontend/package.json', '0.0.4');
124+
await writePackageJson(
125+
repoRoot,
126+
'packages/standalone/package.json',
127+
'0.0.4',
128+
);
129+
await git(repoRoot, ['add', 'packages/server/package.json']);
130+
await git(repoRoot, ['add', 'packages/frontend/package.json']);
131+
await git(repoRoot, ['add', 'packages/standalone/package.json']);
132+
await git(repoRoot, ['commit', '-m', 'chore: release v0.0.4']);
133+
134+
await expect(
135+
performRelease({
136+
cwd: repoRoot,
137+
dryRun: false,
138+
logger: TEST_LOGGER,
139+
releaseType: 'patch',
140+
signTag: false,
141+
}),
142+
).resolves.toEqual({
143+
dryRun: false,
144+
previousTag: 'v0.0.4',
145+
tag: 'v0.0.5',
146+
version: '0.0.5',
147+
});
148+
149+
await expect(git(repoRoot, ['tag', '--list', 'v0.0.5'])).resolves.toBe(
150+
'v0.0.5',
151+
);
152+
await expect(git(repoRoot, ['log', '-1', '--pretty=%s'])).resolves.toBe(
153+
'chore: release v0.0.5',
154+
);
155+
});
156+
121157
test('dry run does not modify package versions or create git objects', async () => {
122158
const result = await performRelease({
123159
cwd: repoRoot,
@@ -136,14 +172,14 @@ describe('performRelease', () => {
136172

137173
await expect(
138174
readVersion(repoRoot, 'packages/server/package.json'),
139-
).resolves.toBe('1.0.0');
175+
).resolves.toBe('0.0.3');
140176
await expect(git(repoRoot, ['tag', '--list', 'v0.1.0'])).resolves.toBe('');
141177
await expect(git(repoRoot, ['log', '-1', '--pretty=%s'])).resolves.toBe(
142178
'chore: seed release fixtures',
143179
);
144180
});
145181

146-
test('fails when there is no release tag', async () => {
182+
test('does not require an existing release tag', async () => {
147183
const emptyRepo = await mkdtemp(
148184
path.join(os.tmpdir(), 'tinyauth-tools-empty-'),
149185
);
@@ -155,6 +191,26 @@ describe('performRelease', () => {
155191
await git(emptyRepo, ['config', 'commit.gpgSign', 'false']);
156192
await git(emptyRepo, ['config', 'tag.gpgSign', 'false']);
157193

194+
await writePackageJson(
195+
emptyRepo,
196+
'packages/server/package.json',
197+
'0.0.3',
198+
);
199+
await writePackageJson(
200+
emptyRepo,
201+
'packages/frontend/package.json',
202+
'0.0.3',
203+
);
204+
await writePackageJson(
205+
emptyRepo,
206+
'packages/standalone/package.json',
207+
'0.0.3',
208+
);
209+
await git(emptyRepo, ['add', 'packages/server/package.json']);
210+
await git(emptyRepo, ['add', 'packages/frontend/package.json']);
211+
await git(emptyRepo, ['add', 'packages/standalone/package.json']);
212+
await git(emptyRepo, ['commit', '-m', 'chore: seed release fixtures']);
213+
158214
await expect(
159215
performRelease({
160216
cwd: emptyRepo,
@@ -163,7 +219,12 @@ describe('performRelease', () => {
163219
releaseType: 'minor',
164220
signTag: false,
165221
}),
166-
).rejects.toThrow('No release tag found');
222+
).resolves.toEqual({
223+
dryRun: false,
224+
previousTag: 'v0.0.3',
225+
tag: 'v0.1.0',
226+
version: '0.1.0',
227+
});
167228
} finally {
168229
await rm(emptyRepo, { force: true, recursive: true });
169230
}
@@ -177,9 +238,9 @@ async function setupRepository(repoRoot: string): Promise<void> {
177238
await git(repoRoot, ['config', 'commit.gpgSign', 'false']);
178239
await git(repoRoot, ['config', 'tag.gpgSign', 'false']);
179240

180-
await writePackageJson(repoRoot, 'packages/server/package.json', '1.0.0');
181-
await writePackageJson(repoRoot, 'packages/frontend/package.json', '1.0.0');
182-
await writePackageJson(repoRoot, 'packages/standalone/package.json', '1.0.0');
241+
await writePackageJson(repoRoot, 'packages/server/package.json', '0.0.3');
242+
await writePackageJson(repoRoot, 'packages/frontend/package.json', '0.0.3');
243+
await writePackageJson(repoRoot, 'packages/standalone/package.json', '0.0.3');
183244

184245
await git(repoRoot, ['add', 'packages/server/package.json']);
185246
await git(repoRoot, ['add', 'packages/frontend/package.json']);

packages/tools/src/lib/release.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@ import {
66
getRepoRoot,
77
getWorktreeStatus,
88
hasTag,
9-
listVersionTags,
109
stageFiles,
1110
} from './git.ts';
1211
import { readPackageVersion, writePackageVersion } from './package-json.ts';
1312
import {
1413
bumpVersion,
1514
formatVersion,
1615
formatVersionTag,
17-
parseVersionTag,
16+
parseVersion,
1817
} from './version.ts';
1918

2019
const RELEASE_TARGETS = [
@@ -57,22 +56,6 @@ export async function performRelease(
5756
throw new Error('Git worktree must be clean before releasing');
5857
}
5958

60-
const tags = await listVersionTags(repoRoot);
61-
const latestTag = tags[0];
62-
63-
if (!latestTag) {
64-
throw new Error('No release tag found. Create a v* tag first.');
65-
}
66-
67-
const currentVersion = parseVersionTag(latestTag);
68-
const nextVersion = bumpVersion(currentVersion, options.releaseType);
69-
const nextTag = formatVersionTag(nextVersion);
70-
const nextVersionText = formatVersion(nextVersion);
71-
72-
if (await hasTag(repoRoot, nextTag)) {
73-
throw new Error(`Release tag already exists: ${nextTag}`);
74-
}
75-
7659
const currentPackageVersions = await Promise.all(
7760
RELEASE_TARGETS.map(async (targetPath) => {
7861
const absolutePath = path.join(repoRoot, targetPath);
@@ -96,8 +79,18 @@ export async function performRelease(
9679
);
9780
}
9881

82+
const currentVersion = parseVersion(expectedVersion);
83+
const currentTag = formatVersionTag(currentVersion);
84+
const nextVersion = bumpVersion(currentVersion, options.releaseType);
85+
const nextTag = formatVersionTag(nextVersion);
86+
const nextVersionText = formatVersion(nextVersion);
87+
88+
if (await hasTag(repoRoot, nextTag)) {
89+
throw new Error(`Release tag already exists: ${nextTag}`);
90+
}
91+
9992
if (options.dryRun) {
100-
options.logger.start(`Dry run for ${nextTag} from ${latestTag}`);
93+
options.logger.start(`Dry run for ${nextTag} from ${currentTag}`);
10194
if (worktreeStatus.length > 0) {
10295
options.logger.info(
10396
'Worktree is dirty; dry run will not modify files or git state',
@@ -109,13 +102,13 @@ export async function performRelease(
109102

110103
return {
111104
dryRun: true,
112-
previousTag: latestTag,
105+
previousTag: currentTag,
113106
tag: nextTag,
114107
version: nextVersionText,
115108
};
116109
}
117110

118-
options.logger.start(`Releasing ${nextTag} from ${latestTag}`);
111+
options.logger.start(`Releasing ${nextTag} from ${currentTag}`);
119112

120113
for (const targetPath of RELEASE_TARGETS) {
121114
const absolutePath = path.join(repoRoot, targetPath);
@@ -135,7 +128,7 @@ export async function performRelease(
135128

136129
return {
137130
dryRun: false,
138-
previousTag: latestTag,
131+
previousTag: currentTag,
139132
tag: nextTag,
140133
version: nextVersionText,
141134
};

packages/tools/src/lib/version.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
bumpVersion,
44
formatVersion,
55
formatVersionTag,
6+
parseVersion,
67
parseVersionTag,
78
} from './version.ts';
89

@@ -15,6 +16,14 @@ describe('version helpers', () => {
1516
});
1617
});
1718

19+
test('parses versions', () => {
20+
expect(parseVersion('0.0.3')).toEqual({
21+
major: 0,
22+
minor: 0,
23+
patch: 3,
24+
});
25+
});
26+
1827
test('bumps versions by release type', () => {
1928
const version = parseVersionTag('v0.0.3');
2029

@@ -26,4 +35,8 @@ describe('version helpers', () => {
2635
test('rejects invalid tags', () => {
2736
expect(() => parseVersionTag('1.2.3')).toThrow('Invalid release tag');
2837
});
38+
39+
test('rejects invalid versions', () => {
40+
expect(() => parseVersion('v1.2.3')).toThrow('Invalid version');
41+
});
2942
});

packages/tools/src/lib/version.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,23 @@ export type Version = {
44
patch: number;
55
};
66

7+
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
78
const VERSION_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;
89

10+
export function parseVersion(version: string): Version {
11+
const match = VERSION_PATTERN.exec(version);
12+
13+
if (!match) {
14+
throw new Error(`Invalid version: ${version}`);
15+
}
16+
17+
const major = Number(match[1]);
18+
const minor = Number(match[2]);
19+
const patch = Number(match[3]);
20+
21+
return { major, minor, patch };
22+
}
23+
924
export function parseVersionTag(tag: string): Version {
1025
const match = VERSION_TAG_PATTERN.exec(tag);
1126

0 commit comments

Comments
 (0)