Skip to content

Commit de32190

Browse files
committed
feat(version): custom tag-version-separator for independent projects
1 parent ea3fb65 commit de32190

6 files changed

Lines changed: 166 additions & 8 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Fixture, normalizeCommitSHAs, normalizeEnvironment } from "@lerna/e2e-utils";
2+
3+
expect.addSnapshotSerializer({
4+
serialize(str: string) {
5+
return normalizeCommitSHAs(normalizeEnvironment(str));
6+
},
7+
test(val: string) {
8+
return val != null && typeof val === "string";
9+
},
10+
});
11+
12+
describe("lerna-version-tag-version-separator qqqq", () => {
13+
let fixture: Fixture;
14+
15+
beforeEach(async () => {
16+
fixture = await Fixture.create({
17+
e2eRoot: process.env.E2E_ROOT,
18+
name: "lerna-version-tag-version-separator",
19+
packageManager: "npm",
20+
initializeGit: true,
21+
lernaInit: { args: [`--packages="packages/*" --independent`] },
22+
installDependencies: true,
23+
});
24+
await fixture.lerna("create package-a -y");
25+
await fixture.lerna("create package-b -y");
26+
await fixture.updateJson("lerna.json", (json) => ({
27+
...json,
28+
command: {
29+
version: {
30+
tagVersionSeparator: "__",
31+
},
32+
},
33+
}));
34+
await fixture.createInitialGitCommit();
35+
await fixture.exec("git push origin test-main");
36+
});
37+
afterEach(() => fixture.destroy());
38+
39+
it("should create and read tags based on the custom tag-version-separator", async () => {
40+
await fixture.lerna("version 3.3.3 -y");
41+
42+
// It should create one tag for each independently versioned package using the custom separator
43+
const checkPackageTagsArePresentLocally = await fixture.exec("git describe --abbrev=0");
44+
expect(checkPackageTagsArePresentLocally.combinedOutput).toMatchInlineSnapshot(`
45+
package-a__3.3.3
46+
47+
`);
48+
49+
const checkPackageATagIsPresentOnRemote = await fixture.exec(
50+
"git ls-remote origin refs/tags/package-a__3.3.3"
51+
);
52+
expect(checkPackageATagIsPresentOnRemote.combinedOutput).toMatchInlineSnapshot(`
53+
{FULL_COMMIT_SHA} refs/tags/package-a__3.3.3
54+
55+
`);
56+
const checkPackageBTagIsPresentOnRemote = await fixture.exec(
57+
"git ls-remote origin refs/tags/package-b__3.3.3"
58+
);
59+
expect(checkPackageBTagIsPresentOnRemote.combinedOutput).toMatchInlineSnapshot(`
60+
{FULL_COMMIT_SHA} refs/tags/package-b__3.3.3
61+
62+
`);
63+
64+
// Update package-a and version using conventional commits (to read from the tag)
65+
await fixture.updateJson("packages/package-a/package.json", (json) => {
66+
return {
67+
...json,
68+
description: json.description + "...with an update!",
69+
};
70+
});
71+
await fixture.exec("git add packages/package-a/package.json");
72+
await fixture.exec("git commit -m 'feat: update package-a'");
73+
await fixture.exec("git push origin test-main");
74+
75+
const output = await fixture.lerna("version --conventional-commits -y", { silenceError: true });
76+
expect(output.combinedOutput).toMatchInlineSnapshot(`
77+
lerna notice cli v999.9.9-e2e.0
78+
lerna info versioning independent
79+
lerna info Looking for changed packages since package-a__3.3.3
80+
lerna info getChangelogConfig Successfully resolved preset "conventional-changelog-angular"
81+
82+
Changes:
83+
- package-a: 3.3.3 => 3.4.0
84+
85+
lerna info auto-confirmed
86+
lerna info execute Skipping releases
87+
lerna info git Pushing tags...
88+
lerna success version finished
89+
90+
`);
91+
});
92+
});

libs/commands/version/src/command.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,12 @@ const command: CommandModule = {
212212
requiresArg: true,
213213
defaultDescription: "v",
214214
},
215+
"tag-version-separator": {
216+
describe: "Customize the tag version separator used when creating tags for independent versioning.",
217+
type: "string",
218+
requiresArg: true,
219+
defaultDescription: "@",
220+
},
215221
"git-tag-command": {
216222
describe:
217223
"Allows users to specify a custom command to be used when applying git tags. For example, this may be useful for providing a wrapper command in CI/CD pipelines that have no direct write access.",

libs/commands/version/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ interface VersionCommandConfigOptions extends CommandConfigOptions {
7575
signGitTag?: boolean;
7676
forceGitTag?: boolean;
7777
tagVersionPrefix?: string;
78+
tagVersionSeparator?: string;
7879
createRelease?: "github" | "gitlab";
7980
changelog?: boolean;
8081
exact?: boolean;
@@ -864,9 +865,10 @@ class VersionCommand extends Command {
864865
}
865866

866867
async gitCommitAndTagVersionForUpdates() {
868+
const tagVersionSeparator = this.options.tagVersionSeparator || "@";
867869
const tags = this.updates.map((node) => {
868870
const pkg = getPackage(node);
869-
return `${pkg.name}@${this.updatesVersions.get(node.name)}`;
871+
return `${pkg.name}${tagVersionSeparator}${this.updatesVersions.get(node.name)}`;
870872
});
871873
const subject = this.options.message || "Publish";
872874
const message = tags.reduce((msg, tag) => `${msg}${os.EOL} - ${tag}`, `${subject}${os.EOL}`);

libs/core/src/lib/collect-updates/collect-project-updates.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export interface ProjectUpdateCollectorOptions {
2929
conventionalGraduate?: string | boolean;
3030
forceConventionalGraduate?: boolean;
3131
excludeDependents?: boolean;
32+
// Separator used within independent version tags, defaults to @
33+
tagVersionSeparator?: string;
3234
}
3335

3436
/**
@@ -46,6 +48,7 @@ export function collectProjectUpdates(
4648
forceConventionalGraduate,
4749
conventionalGraduate,
4850
excludeDependents,
51+
tagVersionSeparator,
4952
} = commandOptions;
5053

5154
// If --conventional-commits and --conventional-graduate are both set, ignore --force-publish but consider --force-conventional-graduate
@@ -59,7 +62,10 @@ export function collectProjectUpdates(
5962
// TODO: refactor to address type issues
6063
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
6164
// @ts-ignore
62-
const { sha, refCount, lastTagName } = describeRefSync(execOpts, commandOptions.includeMergedTags);
65+
const { sha, refCount, lastTagName } = describeRefSync(
66+
{ ...execOpts, separator: tagVersionSeparator },
67+
commandOptions.includeMergedTags
68+
);
6369

6470
if (refCount === "0" && forced.size === 0 && !committish) {
6571
// no commits since previous release

libs/core/src/lib/describe-ref.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,43 @@ describe("parser", () => {
130130
expect(result.isDirty).toBe(true);
131131
});
132132

133+
describe("custom tag-version-separator", () => {
134+
it("matches independent tags using a custom tag-version-separator, CASE 1", () => {
135+
childProcess.execSync.mockReturnValueOnce("pkg-name__1.2.3-4-g567890a");
136+
137+
const result = describeRefSync({ separator: "__" }) as DescribeRefDetailedResult;
138+
139+
expect(result.lastTagName).toBe("pkg-name__1.2.3");
140+
expect(result.lastVersion).toBe("1.2.3");
141+
});
142+
143+
it("matches independent tags using a custom tag-version-separator, CASE 2", () => {
144+
childProcess.execSync.mockReturnValueOnce("pkg-name-1.2.3-4-g567890a");
145+
146+
const result = describeRefSync({ separator: "-" }) as DescribeRefDetailedResult;
147+
148+
expect(result.lastTagName).toBe("pkg-name-1.2.3");
149+
expect(result.lastVersion).toBe("1.2.3");
150+
});
151+
152+
it("matches independent tags for scoped packages", () => {
153+
childProcess.execSync.mockReturnValueOnce("@scope/pkg-name_1.2.3-4-g567890a");
154+
155+
const result = describeRefSync({ separator: "_" }) as DescribeRefDetailedResult;
156+
157+
expect(result.lastTagName).toBe("@scope/pkg-name_1.2.3");
158+
expect(result.lastVersion).toBe("1.2.3");
159+
});
160+
161+
it("matches dirty annotations", () => {
162+
childProcess.execSync.mockReturnValueOnce("pkg-name@@1.2.3-4-g567890a-dirty");
163+
164+
const result = describeRefSync({ separator: "@@" });
165+
166+
expect(result.isDirty).toBe(true);
167+
});
168+
});
169+
133170
it("handles non-matching strings safely", () => {
134171
childProcess.execSync.mockReturnValueOnce("poopy-pants");
135172

libs/core/src/lib/describe-ref.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ interface DescribeRefOptions {
77
cwd?: string | URL;
88
// Glob passed to `--match` flag
99
match?: string;
10+
// Separator used within independent version tags, defaults to @
11+
separator?: string;
1012
}
1113

1214
// When annotated release tags are missing
@@ -60,7 +62,7 @@ export function describeRef(
6062
// TODO: refactor based on TS feedback
6163
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
6264
// @ts-ignore
63-
const result = parse(stdout, options.cwd);
65+
const result = parse(stdout, options.cwd, options.separator);
6466

6567
log.verbose("git-describe", "%j => %j", options && options.match, stdout);
6668
log.silly("git-describe", "parsed => %j", result);
@@ -69,12 +71,15 @@ export function describeRef(
6971
});
7072
}
7173

72-
export function describeRefSync(options = {}, includeMergedTags?: boolean) {
74+
export function describeRefSync(
75+
options: DescribeRefOptions = {},
76+
includeMergedTags?: boolean
77+
): DescribeRefFallbackResult | DescribeRefDetailedResult {
7378
const stdout = childProcess.execSync("git", getArgs(options, includeMergedTags), options);
7479
// TODO: refactor based on TS feedback
7580
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7681
// @ts-ignore
77-
const result = parse(stdout, options.cwd);
82+
const result = parse(stdout, options.cwd, options.separator);
7883

7984
// only called by collect-updates with no matcher
8085
log.silly("git-describe.sync", "%j => %j", stdout, result);
@@ -86,8 +91,15 @@ export function describeRefSync(options = {}, includeMergedTags?: boolean) {
8691
* Parse git output and return relevant metadata.
8792
* @param stdout Result of `git describe`
8893
* @param [cwd] Defaults to `process.cwd()`
94+
* @param [separator] Separator used within independent version tags, defaults to @
8995
*/
90-
function parse(stdout: string, cwd: string): DescribeRefFallbackResult | DescribeRefDetailedResult {
96+
function parse(
97+
stdout: string,
98+
cwd: string,
99+
separator: string
100+
): DescribeRefFallbackResult | DescribeRefDetailedResult {
101+
separator = separator || "@";
102+
91103
const minimalShaRegex = /^([0-9a-f]{7,40})(-dirty)?$/;
92104
// when git describe fails to locate tags, it returns only the minimal sha
93105
if (minimalShaRegex.test(stdout)) {
@@ -103,8 +115,11 @@ function parse(stdout: string, cwd: string): DescribeRefFallbackResult | Describ
103115
return { refCount, sha, isDirty: Boolean(isDirty) };
104116
}
105117

106-
const [, lastTagName, lastVersion, refCount, sha, isDirty] =
107-
/^((?:.*@)?(.*))-(\d+)-g([0-9a-f]+)(-dirty)?$/.exec(stdout) || [];
118+
// If the user has specified a custom separator, it may not be regex-safe, so escape it
119+
const escapedSeparator = separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120+
const regexPattern = new RegExp(`^((?:.*${escapedSeparator})?(.*))-(\\d+)-g([0-9a-f]+)(-dirty)?$`);
121+
122+
const [, lastTagName, lastVersion, refCount, sha, isDirty] = regexPattern.exec(stdout) || [];
108123

109124
return { lastTagName, lastVersion, refCount, sha, isDirty: Boolean(isDirty) };
110125
}

0 commit comments

Comments
 (0)