Skip to content

Commit c0d4031

Browse files
committed
feat: add option versionCmd #20
versionCmd can be used to dynamically set the version
1 parent 3210d50 commit c0d4031

13 files changed

+187
-61
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Working examples using Github Actions can be found here:
9494
| ```gpgIdentity``` | str | ```null``` | When ```gpgSign``` is true, set the GPG identify to use when signing files. Leave empty to use the default identity.
9595
| ```envDir``` | string \| ```false``` | ```.venv``` | directory to create the virtual environment in, if set to `false` no environment will be created
9696
| ```installDeps``` | bool | ```true``` | wether to automatically install python dependencies
97+
| ```versionCmd``` | string | ```undefined``` | Run a custom command to update the version (e.g. `hatch version ${version}`). `srcDir` is used as working directory. `versionCmd` is required if the version is set [dynamically](https://packaging.python.org/en/latest/specifications/pyproject-toml/#dynamic)
9798

9899
## Development
99100

lib/default-options.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,8 @@ export class DefaultConfig {
4747
public get installDeps() {
4848
return this.config.installDeps ?? true;
4949
}
50+
51+
public get versionCmd() {
52+
return this.config.versionCmd;
53+
}
5054
}

lib/prepare.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Options } from 'execa';
22
import fs from 'fs';
3+
import { template } from 'lodash';
34
import os from 'os';
45
import path from 'path';
56
import type { Context } from './@types/semantic-release/index.js';
@@ -82,9 +83,8 @@ async function createVenv(envDir: string, options?: Options): Promise<Options> {
8283

8384
async function prepare(pluginConfig: PluginConfig, context: Context) {
8485
const { logger, nextRelease } = context;
85-
const { srcDir, setupPath, distDir, envDir, installDeps } = new DefaultConfig(
86-
pluginConfig,
87-
);
86+
const { srcDir, setupPath, distDir, envDir, installDeps, versionCmd } =
87+
new DefaultConfig(pluginConfig);
8888

8989
if (nextRelease === undefined) {
9090
throw new Error('nextRelease is undefined');
@@ -112,10 +112,16 @@ async function prepare(pluginConfig: PluginConfig, context: Context) {
112112

113113
const version = await normalizeVersion(nextRelease.version, execaOptions);
114114

115-
if (isLegacyBuildInterface(srcDir)) {
115+
if (versionCmd) {
116+
const cmd = template(versionCmd)({ version });
117+
logger.log(`Running versionCmd: ${cmd}`);
118+
const [file, ...args] = cmd.split(' ');
119+
await assertExitCode(file, args, { ...execaOptions, cwd: srcDir }, 0);
120+
} else if (isLegacyBuildInterface(srcDir)) {
116121
logger.log(`Set version to ${version} (setup.cfg)`);
117122
await setVersionPy(setupPath, version);
118123
} else {
124+
logger.log(`Set version to ${version} (pyproject.toml)`);
119125
await setVersionToml(srcDir, version, execaOptions);
120126
}
121127

lib/py/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ build~=1.0
33
setuptools~=68.2
44
twine>=5.1.1,<6
55
tomlkit~=0.12
6-
packaging~=24.0
6+
packaging~=24.0
7+
hatch~=1.13.0

lib/py/set_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# modifiy version (tomlkit will preserve comments)
1919
toml = parse(pyproject)
2020
if 'tool' in toml and 'poetry' in toml['tool']:
21-
print(f'Poetry package detected')
21+
print('Poetry package detected')
2222
toml['tool']['poetry']['version'] = args.version
2323
else:
2424
toml['project']['version'] = args.version

lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export interface PluginConfig {
77
gpgIdentity?: string;
88
envDir?: string | false;
99
installDeps?: boolean;
10+
versionCmd?: string;
1011
}

lib/verify.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import FormData from 'form-data';
33
import fs from 'fs';
44
import got from 'got';
55
import path from 'path';
6+
import TOML from 'smol-toml';
67
import { Context } from './@types/semantic-release/index.js';
78
import { DefaultConfig } from './default-options.js';
89
import { PluginConfig } from './types.js';
@@ -28,7 +29,8 @@ async function assertExitCode(
2829
}
2930
if (res.exitCode != exitCode) {
3031
throw Error(
31-
`command: ${res.command}, exit code: ${res.exitCode}, expected: ${exitCode}`,
32+
res.stderr +
33+
`\ncommand: ${res.command}, exit code: ${res.exitCode}, expected: ${exitCode}`,
3234
);
3335
}
3436
}
@@ -79,11 +81,17 @@ function isLegacyBuildInterface(srcDir: string): boolean {
7981
return !fs.statSync(pyprojectPath).isFile;
8082
}
8183

84+
function assertVersionCmd(pyproject: any, versionCmd?: string) {
85+
const dynamic: string[] = pyproject.project?.dynamic ?? [];
86+
if (dynamic.includes('version') && !versionCmd) {
87+
throw Error(`'versionCmd' is required when using a dynamic version`);
88+
}
89+
}
90+
8291
async function verify(pluginConfig: PluginConfig, context: Context) {
8392
const { logger } = context;
84-
const { srcDir, setupPath, pypiPublish, repoUrl } = new DefaultConfig(
85-
pluginConfig,
86-
);
93+
const { srcDir, setupPath, pypiPublish, repoUrl, versionCmd } =
94+
new DefaultConfig(pluginConfig);
8795

8896
const execaOptions: Options = pipe(context);
8997

@@ -109,6 +117,14 @@ async function verify(pluginConfig: PluginConfig, context: Context) {
109117

110118
logger.log('Verify that version is not set in setup.py');
111119
await verifySetupPy(setupPath, execaOptions);
120+
} else {
121+
const pyprojectPath = path.join(srcDir, 'pyproject.toml');
122+
const toml = fs.readFileSync(pyprojectPath, {
123+
encoding: 'utf8',
124+
flag: 'r',
125+
});
126+
const pyproject = TOML.parse(toml);
127+
assertVersionCmd(pyproject, versionCmd);
112128
}
113129
}
114130

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"private": false,
1212
"devDependencies": {
1313
"@semantic-release/git": "^10.0.1",
14+
"@types/lodash": "^4.17.13",
1415
"@types/node": "^22.7.7",
1516
"@types/uuid": "^9.0.4",
1617
"@typescript-eslint/eslint-plugin": "^6.7.4",
@@ -37,7 +38,9 @@
3738
"dependencies": {
3839
"execa": "^9.4.1",
3940
"form-data": "^3.0.0",
40-
"got": "^12.6.1"
41+
"got": "^12.6.1",
42+
"lodash": "^4.17.21",
43+
"smol-toml": "^1.3.1"
4144
},
4245
"files": [
4346
"dist/*",

test/package.test.ts

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,6 @@
1-
import { PassThrough } from 'stream';
21
import { expect, test } from 'vitest';
32
import { prepare, publish, verifyConditions } from '../lib/index.js';
4-
import { genPackage, hasPackage } from './util.js';
5-
6-
class EndlessPassThrough extends PassThrough {
7-
end(): this {
8-
return this;
9-
}
10-
close() {
11-
super.end();
12-
}
13-
}
3+
import { genPackage, hasPackage, OutputAnalyzer } from './util.js';
144

155
test('test semantic-release-pypi (pyproject.toml)', async () => {
166
if (!process.env['TESTPYPI_TOKEN']) {
@@ -78,26 +68,52 @@ build-backend = "poetry.core.masonry.api"`;
7868
},
7969
});
8070

81-
const stream = new EndlessPassThrough();
82-
context.stdout = stream;
83-
84-
let built = false;
85-
stream.on('data', (bytes) => {
86-
const str = bytes.toString('utf-8');
87-
if (
88-
str.includes('Successfully built') &&
89-
str.includes('poetry_demo-1.0.0')
90-
) {
91-
built = true;
92-
}
71+
const outputAnalyzer = new OutputAnalyzer({
72+
built: ['Successfully built', 'poetry_demo-1.0.0'],
9373
});
74+
context.stdout = outputAnalyzer.stream;
9475

9576
await verifyConditions(config, context);
9677
await prepare(config, context);
9778
await publish(config, context);
9879

99-
expect(built).toEqual(true);
80+
expect(outputAnalyzer.res.built).toEqual(true);
10081
expect(context.logger.log).toHaveBeenCalledWith(
10182
'Not publishing package due to requested configuration',
10283
);
10384
}, 60000);
85+
86+
test('semantic-release-pypi (hatch, dynamic version)', async () => {
87+
const pyproject = `[project]
88+
name = "hatch-demo"
89+
dynamic = ["version"]
90+
91+
[build-system]
92+
requires = ["hatchling"]
93+
build-backend = "hatchling.build"
94+
95+
[tool.hatch.version]
96+
path = "hatch_demo/__init__.py"`;
97+
98+
const { config, context } = await genPackage({
99+
legacyInterface: false,
100+
config: { pypiPublish: false, versionCmd: 'hatch version ${version}' },
101+
content: pyproject,
102+
files: {
103+
hatch_demo: {
104+
'__init__.py': '__version__ = "0.0.0"\n',
105+
},
106+
},
107+
});
108+
109+
const outputAnalyzer = new OutputAnalyzer({
110+
built: ['Successfully built', 'hatch_demo-1.0.0'],
111+
});
112+
context.stdout = outputAnalyzer.stream;
113+
114+
await verifyConditions(config, context);
115+
await prepare(config, context);
116+
await publish(config, context);
117+
118+
expect(outputAnalyzer.res.built).toEqual(true);
119+
}, 60000);

test/prepare.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import {
33
bDistPackage,
44
createVenv,
55
installPackages,
6+
prepare,
67
sDistPackage,
78
setVersionPy,
89
setVersionToml,
910
} from '../lib/prepare.js';
1011
import { pipe, spawn } from '../lib/util.js';
1112
import { assertPackage } from '../lib/verify.js';
12-
import { genPackage } from './util.js';
13+
import { genPackage, OutputAnalyzer } from './util.js';
1314

1415
describe('prepare: build functions', () => {
1516
const testCases = [
@@ -60,6 +61,23 @@ test('prepare: setVersionToml', async () => {
6061
).resolves.toBe(undefined);
6162
});
6263

64+
test('prepare: versionCmd', async () => {
65+
const { pluginConfig, context } = genPackage({
66+
legacyInterface: false,
67+
config: {
68+
versionCmd: 'echo Next version: ${version}',
69+
},
70+
});
71+
72+
const outputAnalyzer = new OutputAnalyzer({
73+
executed: ['Next version: 1.0.0'],
74+
});
75+
context.stdout = outputAnalyzer.stream;
76+
77+
await prepare(pluginConfig, context);
78+
expect(outputAnalyzer.res.executed).toBe(true);
79+
}, 60000);
80+
6381
describe('prepare: installPackages', () => {
6482
const tests = [
6583
{ name: 'system', opt: async () => ({}) },

0 commit comments

Comments
 (0)