Skip to content

Commit 47a8038

Browse files
feat: add support for updating from a build version file
1 parent ba004a7 commit 47a8038

File tree

9 files changed

+434
-65
lines changed

9 files changed

+434
-65
lines changed

README.md

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,17 @@ The example configuration above will version and git commit your native files.
4343

4444
## Configuration
4545

46-
| Property | Description | Default |
47-
|-------------------|--------------------------------------------------------------------|----------------------------|
48-
| `androidPath` | Path to your "android/app/build.gradle" file. | `android/app/build.gradle` |
49-
| `iosPath` | Path to your "ios/" folder. | `ios` |
50-
| `skipBuildNumber` | Do not increment the build number for either platform. | `false` |
51-
| `skipAndroid` | Skip Android versioning. | `false` |
52-
| `skipIos` | Skip iOS versioning. | `false` |
53-
| `iosPackageName` | Only update iOS projects that have the given name. | `null` |
54-
| `noPrerelease` | Skip pre-release versions entirely for both platforms. | `false` |
55-
| `versionStrategy` | Specifies the versioning strategies for each platform (see below). | `{"android": {"buildNumber": "increment", "preRelease": true}, "ios": {"buildNumber": "strict", "preRelease": true}}` |
46+
| Property | Description | Default |
47+
|-------------------|--------------------------------------------------------------------------------|----------------------------|
48+
| `androidPath` | Path to your "android/app/build.gradle" file. | `android/app/build.gradle` |
49+
| `iosPath` | Path to your "ios/" folder. | `ios` |
50+
| `skipBuildNumber` | Do not increment the build number for either platform. | `false` |
51+
| `skipAndroid` | Skip Android versioning. | `false` |
52+
| `skipIos` | Skip iOS versioning. | `false` |
53+
| `iosPackageName` | Only update iOS projects that have the given name. | `null` |
54+
| `noPrerelease` | Skip pre-release versions entirely for both platforms. | `false` |
55+
| `fromFile` | Use a JSON file (e.g. `.versionrc.json`) to read and write the version number. | `null` |
56+
| `versionStrategy` | Specifies the versioning strategies for each platform (see below). | `{"android": {"buildNumber": "increment", "preRelease": true}, "ios": {"buildNumber": "strict", "preRelease": true}}` |
5657

5758
## Versioning strategies
5859

@@ -243,3 +244,31 @@ If these variables are not present in the first place then nothing will be added
243244
Whether or not you choose to commit these updates is up to you. If you are not
244245
actually using these variables in your `Info.plist` files then they are probably
245246
redundant anyway.
247+
248+
## Build version file
249+
250+
If you do not want to update the `Info.plist` and `build.gradle` files directly you
251+
can read and write the build version to a JSON file instead, using the `fromFile` option.
252+
This can be useful for supporting Expo projects, where this version file can then be loaded
253+
into your [app config](https://docs.expo.dev/workflow/configuration/).
254+
255+
You can call this file whatever you like, for example:
256+
257+
```json
258+
{
259+
"plugins": [
260+
["semantic-release-react-native", {
261+
"fromFile": ".versionrc.json",
262+
}],
263+
]
264+
}
265+
```
266+
267+
The file will be output in the following format:
268+
269+
```json
270+
{
271+
"android": 5322,
272+
"ios": "3837.15.99"
273+
}
274+
```

src/errors.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ type ErrorCodes = 'ENRNANDROIDPATH'
2323
| 'ENRNIOSPATH'
2424
| 'ENRNNOTBOOLEAN'
2525
| 'ENRNNOTSTRING'
26-
| 'ENRNVERSIONSTRATEGY';
26+
| 'ENRNVERSIONSTRATEGY'
27+
| 'ENRNFROMFILENOTJSON';
2728

2829
type ErrorDefinition = (key: keyof PluginConfig) => {
2930
message: string;
@@ -51,10 +52,39 @@ const ERROR_DEFINITIONS: Record<ErrorCodes, ErrorDefinition> = {
5152
message: `Invalid ${key}`,
5253
details: `The ${key} must comply with the schema (see docs).`,
5354
}),
55+
ENRNFROMFILENOTJSON: (key: keyof PluginConfig) => ({
56+
message: `Invalid ${key}`,
57+
details: `The ${key} must point to a JSON file.`,
58+
}),
5459
};
5560

56-
export const getError = (key: keyof PluginConfig, code: ErrorCodes) => {
61+
export const getSemanticReleaseError = (key: keyof PluginConfig, code: ErrorCodes) => {
5762
const { message, details } = ERROR_DEFINITIONS[code](key);
5863

5964
return new SemanticReleaseError(message, code, details);
6065
};
66+
67+
const isError = (error: unknown): error is Error => (
68+
typeof error === 'object'
69+
&& error !== null
70+
&& 'message' in error
71+
&& typeof (error as Record<string, unknown>).message === 'string'
72+
);
73+
74+
export const toError = (maybeError: unknown): Error => {
75+
if (isError(maybeError)) {
76+
return maybeError;
77+
}
78+
79+
if (typeof maybeError === 'string') {
80+
return new Error(maybeError);
81+
}
82+
83+
try {
84+
return new Error(JSON.stringify(maybeError));
85+
} catch {
86+
// Fallback in case there's an error stringifying the maybeError,
87+
// like with circular references, for example.
88+
return new Error(String(maybeError));
89+
}
90+
};

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type PluginConfig = {
1212
skipAndroid?: boolean;
1313
skipIos?: boolean;
1414
noPrerelease?: boolean;
15+
fromFile?: string;
1516
versionStrategy?: {
1617
android?: {
1718
buildNumber?: AndroidVersionStrategies;
@@ -25,3 +26,8 @@ export type PluginConfig = {
2526
};
2627

2728
export type FullPluginConfig = Required<PluginConfig>;
29+
30+
export type VersionFile = {
31+
android?: string;
32+
ios?: string;
33+
};

src/verify.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import fs from 'fs';
2+
import path from 'path';
3+
import appRoot from 'app-root-path';
24
import { PluginConfig } from './types';
35
import { toAbsolutePath } from './paths';
4-
import { getError } from './errors';
6+
import { getSemanticReleaseError } from './errors';
57
import { iosVesionStrategies } from './strategies';
68

79
const verifyAndroidPath = (androidPath: string) => {
810
const absAndroidPath = toAbsolutePath(androidPath);
911

1012
if (!absAndroidPath.endsWith('build.gradle')) {
11-
return getError('androidPath', 'ENRNANDROIDPATH');
13+
return getSemanticReleaseError('androidPath', 'ENRNANDROIDPATH');
1214
}
1315

1416
if (!fs.existsSync(absAndroidPath)) {
15-
return getError('androidPath', 'ENRNANDROIDPATH');
17+
return getSemanticReleaseError('androidPath', 'ENRNANDROIDPATH');
1618
}
1719

1820
return null;
@@ -22,11 +24,27 @@ const verifyIosPath = (iosPath: string) => {
2224
const absIosPath = toAbsolutePath(iosPath);
2325

2426
if (!fs.existsSync(absIosPath)) {
25-
return getError('iosPath', 'ENRNIOSPATH');
27+
return getSemanticReleaseError('iosPath', 'ENRNIOSPATH');
2628
}
2729

2830
if (!fs.lstatSync(absIosPath).isDirectory()) {
29-
return getError('iosPath', 'ENRNIOSPATH');
31+
return getSemanticReleaseError('iosPath', 'ENRNIOSPATH');
32+
}
33+
34+
return null;
35+
};
36+
37+
const verifyJSONFile = (fileName: string) => {
38+
const filePath = path.join(appRoot.path, fileName);
39+
40+
if (!fs.existsSync(filePath)) {
41+
return null;
42+
}
43+
44+
try {
45+
JSON.parse(fs.readFileSync(filePath, 'utf8'));
46+
} catch (error) {
47+
return getSemanticReleaseError('fromFile', 'ENRNFROMFILENOTJSON');
3048
}
3149

3250
return null;
@@ -43,6 +61,10 @@ export const verifyConditons = (pluginConfig: PluginConfig) => {
4361
errors.push(verifyIosPath(pluginConfig.iosPath));
4462
}
4563

64+
if (pluginConfig.fromFile) {
65+
errors.push(verifyJSONFile(pluginConfig.fromFile));
66+
}
67+
4668
const booleanValues: (keyof PluginConfig)[] = [
4769
'skipBuildNumber',
4870
'skipAndroid',
@@ -52,33 +74,33 @@ export const verifyConditons = (pluginConfig: PluginConfig) => {
5274

5375
booleanValues.forEach((key) => {
5476
if (key in pluginConfig && typeof pluginConfig[key] !== 'boolean') {
55-
errors.push(getError(key, 'ENRNNOTBOOLEAN'));
77+
errors.push(getSemanticReleaseError(key, 'ENRNNOTBOOLEAN'));
5678
}
5779
});
5880

5981
if (
6082
'iosPackageName' in pluginConfig
6183
&& !(typeof pluginConfig.iosPackageName === 'string' || pluginConfig.iosPackageName instanceof String)
6284
) {
63-
errors.push(getError('iosPackageName', 'ENRNNOTSTRING'));
85+
errors.push(getSemanticReleaseError('iosPackageName', 'ENRNNOTSTRING'));
6486
}
6587

6688
const { ios, android } = pluginConfig.versionStrategy ?? {};
6789

6890
if (ios?.buildNumber && !iosVesionStrategies.includes(ios.buildNumber)) {
69-
errors.push(getError('versionStrategy', 'ENRNVERSIONSTRATEGY'));
91+
errors.push(getSemanticReleaseError('versionStrategy', 'ENRNVERSIONSTRATEGY'));
7092
}
7193

7294
if (android?.buildNumber && !iosVesionStrategies.includes(android.buildNumber)) {
73-
errors.push(getError('versionStrategy', 'ENRNVERSIONSTRATEGY'));
95+
errors.push(getSemanticReleaseError('versionStrategy', 'ENRNVERSIONSTRATEGY'));
7496
}
7597

7698
if (ios?.preRelease != null && typeof ios.preRelease !== 'boolean') {
77-
errors.push(getError('versionStrategy', 'ENRNVERSIONSTRATEGY'));
99+
errors.push(getSemanticReleaseError('versionStrategy', 'ENRNVERSIONSTRATEGY'));
78100
}
79101

80102
if (android?.preRelease != null && typeof android.preRelease !== 'boolean') {
81-
errors.push(getError('versionStrategy', 'ENRNVERSIONSTRATEGY'));
103+
errors.push(getSemanticReleaseError('versionStrategy', 'ENRNVERSIONSTRATEGY'));
82104
}
83105

84106
return errors.filter((x) => x);

src/version/android.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import path from 'path';
33
import type { Context } from 'semantic-release';
44
import type { FullPluginConfig } from '../types';
55
import { toAbsolutePath } from '../paths';
6-
import { getSemanticBuildNumber, isPreRelease } from './utils';
6+
import {
7+
getSemanticBuildNumber,
8+
isPreRelease,
9+
loadBuildVersionFile,
10+
writeBuildVersionFile,
11+
} from './utils';
712

813
/**
914
* Get the path to the Android bundle.gradle file.
@@ -83,6 +88,34 @@ const getNextAndroidVersionCode = (
8388
return String(parseInt(currentVersionCode, 10) + 1);
8489
};
8590

91+
/**
92+
* Update a version file, rather than the build.gradle.
93+
*/
94+
const versionFromFile = (
95+
{ versionStrategy, fromFile, skipBuildNumber }: FullPluginConfig,
96+
{ logger }: Context,
97+
version: string,
98+
) => {
99+
if (skipBuildNumber) {
100+
logger.info('Skipping update of Android build number');
101+
102+
return;
103+
}
104+
105+
const versionFile = loadBuildVersionFile(fromFile);
106+
const nextBuildVersion = getNextAndroidVersionCode(
107+
versionStrategy.android,
108+
logger,
109+
version,
110+
versionFile.android ?? '0',
111+
);
112+
113+
writeBuildVersionFile(fromFile, {
114+
...versionFile,
115+
android: nextBuildVersion,
116+
});
117+
};
118+
86119
/**
87120
* Update Android files with the new version.
88121
*
@@ -108,6 +141,13 @@ export const versionAndroid = (
108141
return;
109142
}
110143

144+
if (pluginConfig.fromFile) {
145+
logger.info('Versioning Android from file');
146+
versionFromFile(pluginConfig, context, version);
147+
148+
return;
149+
}
150+
111151
logger.info('Versioning Android');
112152

113153
const androidPath = getAndroidPath(pluginConfig.androidPath);

src/version/ios.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import htmlMinifier from 'html-minifier';
1010
import type { Context } from 'semantic-release';
1111
import type { FullPluginConfig } from '../types';
1212
import { toAbsolutePath } from '../paths';
13-
import { getSemanticBuildNumber, isPreRelease, stripPrereleaseVersion } from './utils';
13+
import {
14+
getSemanticBuildNumber,
15+
isPreRelease,
16+
loadBuildVersionFile,
17+
stripPrereleaseVersion,
18+
writeBuildVersionFile,
19+
} from './utils';
1420

1521
/**
1622
* Get the path to the iOS Xcode project file.
@@ -375,6 +381,34 @@ const incrementPlistVersions = (
375381
});
376382
};
377383

384+
/**
385+
* Update a version file, rather than the Info.plist and Xcode project files.
386+
*/
387+
const versionFromFile = (
388+
{ versionStrategy, fromFile, skipBuildNumber }: FullPluginConfig,
389+
{ logger }: Context,
390+
version: string,
391+
) => {
392+
if (skipBuildNumber) {
393+
logger.info('Skipping update of iOS Android build number');
394+
395+
return;
396+
}
397+
398+
const versionFile = loadBuildVersionFile(fromFile);
399+
const nextBuildVersion = getIosBundleVersion(
400+
versionStrategy.ios,
401+
logger,
402+
versionFile.ios ?? '0',
403+
version,
404+
);
405+
406+
writeBuildVersionFile(fromFile, {
407+
...versionFile,
408+
ios: nextBuildVersion,
409+
});
410+
};
411+
378412
/**
379413
* Version iOS files.
380414
*/
@@ -407,6 +441,13 @@ export const versionIos = (
407441
return;
408442
}
409443

444+
if (pluginConfig.fromFile) {
445+
logger.info('Versioning iOS from file');
446+
versionFromFile(pluginConfig, context, version);
447+
448+
return;
449+
}
450+
410451
logger.info('Versioning iOS');
411452

412453
const iosPath = getIosPath(pluginConfig.iosPath);

0 commit comments

Comments
 (0)