Skip to content

Commit cf60321

Browse files
CopilotAlCalzone
andauthored
Add branch pattern check to prevent accidental releases from non-configured branches (#182)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: AlCalzone <[email protected]> Co-authored-by: Dominic Griesel <[email protected]>
1 parent 31e438d commit cf60321

File tree

3 files changed

+261
-19
lines changed

3 files changed

+261
-19
lines changed

README.md

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22

33
Automate the monotonous tasks when it comes to releasing new versions of your project. The features include:
44

5-
- Bump the version in `package.json` (and `package-lock.json`)
6-
- Update the changelog headline with the new version and release date whenever a release is made
7-
- Move old changelog entries into another file
8-
- Add the changelog to the release commit and create a tag for it.
9-
- Support for monorepos managed with `lerna` or `yarn workspaces` (using additional `yarn` plugins)
10-
- Support for custom scripts during the release lifecycle
11-
- Check licenses for outdated copyrights
12-
- Dry runs and checking for errors
13-
- **ioBroker only**:
14-
- Bump the version in `io-package.json`
15-
- Update the `news` in `io-package.json` and auto-translate the changelog to the other languages
16-
- Remove old `news`
5+
- Bump the version in `package.json` (and `package-lock.json`)
6+
- Update the changelog headline with the new version and release date whenever a release is made
7+
- Move old changelog entries into another file
8+
- Add the changelog to the release commit and create a tag for it.
9+
- Support for monorepos managed with `lerna` or `yarn workspaces` (using additional `yarn` plugins)
10+
- Support for custom scripts during the release lifecycle
11+
- Check licenses for outdated copyrights
12+
- Dry runs and checking for errors
13+
- **ioBroker only**:
14+
- Bump the version in `io-package.json`
15+
- Update the `news` in `io-package.json` and auto-translate the changelog to the other languages
16+
- Remove old `news`
1717

1818
Together with the corresponding **Github Actions** workflow (more on that below) this enables auto-publishing on `npm` and `Github Releases` if the build was successful.
1919

@@ -72,8 +72,8 @@ In order to use this script, you need to maintain the changelog in either `READM
7272
7373
## **WORK IN PROGRESS**
7474
75-
- Did some changes
76-
- Did some more changes
75+
- Did some changes
76+
- Did some more changes
7777
7878
## v0.0.1 (2020-01-01)
7979
@@ -166,8 +166,8 @@ Instead of manually providing all options, you can configure the release process
166166
"plugins": ["iobroker", "lerna"],
167167
// Objects
168168
"exec": {
169-
"before_commit": "echo Hello World!"
170-
}
169+
"before_commit": "echo Hello World!",
170+
},
171171
}
172172
```
173173
@@ -291,7 +291,7 @@ or to `.releaseconfig.json` if you're using that:
291291
```jsonc
292292
{
293293
// ... other options
294-
"plugins": ["iobroker", "license"]
294+
"plugins": ["iobroker", "license"],
295295
}
296296
```
297297
@@ -342,6 +342,14 @@ When this option is set, nothing will be pushed to the remote. This option can b
342342
npm run release patch -- --noPush
343343
```
344344
345+
#### Configure branches from which releases can be created (`--branchPattern`)
346+
347+
By default, the release script only allows creating releases from the repository's default branch (usually `main` or `master`). If you want to allow releases from other branches, you can use this option. It supports `*` as a wildcard, e.g. `release/*` to allow releases from all branches starting with `release/`. Can be specified multiple times.
348+
349+
```bash
350+
npm run release patch -- --branchPattern main --branchPattern release/*
351+
```
352+
345353
### `changelog` plugin options
346354
347355
#### Limit the number of entries in README.md (`--numChangelogEntries` or `-n`)
@@ -402,8 +410,8 @@ To run custom scripts, you can use these options. They accept an object defining
402410
// Run "echo 1", "echo 2", "echo 3" after the "push" stage
403411
"after_push": ["echo 1", "echo 2", "echo 3"],
404412
// Run "sudo shutdown" before the "check" stage
405-
"before_check": "sudo shutdown"
406-
}
413+
"before_check": "sudo shutdown",
414+
},
407415
}
408416
```
409417

packages/plugin-git/src/index.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe("Git plugin", () => {
1515
context.sys.mockExec({
1616
"git config --get user.name": "",
1717
"git config --get user.email": "",
18+
"git rev-parse --abbrev-ref HEAD": "main",
1819
});
1920

2021
await assertReleaseError(() => gitPlugin.executeStage(context, DefaultStages.check), {
@@ -31,6 +32,7 @@ describe("Git plugin", () => {
3132
context.sys.mockExec({
3233
"git config --get user.name": "henlo",
3334
"git config --get user.email": "this.is@dog",
35+
"git rev-parse --abbrev-ref HEAD": "main",
3436
"git rev-list --left-right --count HEAD...origin": "0\t2",
3537
});
3638

@@ -48,6 +50,7 @@ describe("Git plugin", () => {
4850
context.sys.mockExec({
4951
"git config --get user.name": "henlo",
5052
"git config --get user.email": "this.is@dog",
53+
"git rev-parse --abbrev-ref HEAD": "main",
5154
"git rev-list --left-right --count HEAD...origin": "1\t1",
5255
});
5356

@@ -66,6 +69,7 @@ describe("Git plugin", () => {
6669
context.sys.mockExec({
6770
"git config --get user.name": "henlo",
6871
"git config --get user.email": "this.is@dog",
72+
"git rev-parse --abbrev-ref HEAD": "main",
6973
"git rev-list --left-right --count HEAD...origin": "1\t0",
7074
"git status --porcelain": "whatever",
7175
});
@@ -84,6 +88,7 @@ describe("Git plugin", () => {
8488
context.sys.mockExec({
8589
"git config --get user.name": "henlo",
8690
"git config --get user.email": "this.is@dog",
91+
"git rev-parse --abbrev-ref HEAD": "main",
8792
"git rev-list --left-right --count HEAD...origin": "1\t0",
8893
"git status --porcelain": "whatever",
8994
});
@@ -100,13 +105,166 @@ describe("Git plugin", () => {
100105
context.sys.mockExec({
101106
"git config --get user.name": "henlo",
102107
"git config --get user.email": "this.is@dog",
108+
"git rev-parse --abbrev-ref HEAD": "main",
109+
"git symbolic-ref --short refs/remotes/origin/HEAD": "origin/main",
103110
"git rev-list --left-right --count HEAD...origin": "1\t0",
104111
"git status --porcelain": "",
105112
});
106113

107114
await gitPlugin.executeStage(context, DefaultStages.check);
108115
expect(context.errors).toHaveLength(0);
109116
});
117+
118+
it("succeeds when on the 'main' branch (default pattern)", async () => {
119+
const gitPlugin = new GitPlugin();
120+
const context = createMockContext({
121+
plugins: [gitPlugin],
122+
});
123+
context.sys.mockExec({
124+
"git config --get user.name": "henlo",
125+
"git config --get user.email": "this.is@dog",
126+
"git rev-parse --abbrev-ref HEAD": "main",
127+
"git symbolic-ref --short refs/remotes/origin/HEAD": "origin/main",
128+
"git rev-list --left-right --count HEAD...origin": "1\t0",
129+
"git status --porcelain": "",
130+
});
131+
132+
await gitPlugin.executeStage(context, DefaultStages.check);
133+
expect(context.errors).toHaveLength(0);
134+
});
135+
136+
it("succeeds when on the 'master' branch (default pattern)", async () => {
137+
const gitPlugin = new GitPlugin();
138+
const context = createMockContext({
139+
plugins: [gitPlugin],
140+
});
141+
context.sys.mockExec({
142+
"git config --get user.name": "henlo",
143+
"git config --get user.email": "this.is@dog",
144+
"git rev-parse --abbrev-ref HEAD": "master",
145+
"git symbolic-ref --short refs/remotes/origin/HEAD": "origin/master",
146+
"git rev-list --left-right --count HEAD...origin": "1\t0",
147+
"git status --porcelain": "",
148+
});
149+
150+
await gitPlugin.executeStage(context, DefaultStages.check);
151+
expect(context.errors).toHaveLength(0);
152+
});
153+
154+
it("raises a fatal error when not on an allowed branch", async () => {
155+
const gitPlugin = new GitPlugin();
156+
const context = createMockContext({
157+
plugins: [gitPlugin],
158+
});
159+
context.sys.mockExec((cmd) => {
160+
if (cmd === "git config --get user.name") return "henlo";
161+
if (cmd === "git config --get user.email") return "this.is@dog";
162+
if (cmd === "git rev-parse --abbrev-ref HEAD") return "feature-branch";
163+
if (cmd === "git symbolic-ref --short refs/remotes/origin/HEAD") {
164+
// Simulate failure - fall back to ["main", "master"]
165+
throw new Error("not a symbolic ref");
166+
}
167+
throw new Error(`mock missing for command "${cmd}"!`);
168+
});
169+
170+
await assertReleaseError(() => gitPlugin.executeStage(context, DefaultStages.check), {
171+
fatal: true,
172+
messageMatches: /Release can only be triggered from/i,
173+
});
174+
});
175+
176+
it("uses dynamic default branch from git symbolic-ref", async () => {
177+
const gitPlugin = new GitPlugin();
178+
const context = createMockContext({
179+
plugins: [gitPlugin],
180+
});
181+
context.sys.mockExec({
182+
"git config --get user.name": "henlo",
183+
"git config --get user.email": "this.is@dog",
184+
"git rev-parse --abbrev-ref HEAD": "develop",
185+
"git symbolic-ref --short refs/remotes/origin/HEAD": "origin/develop",
186+
"git rev-list --left-right --count HEAD...origin": "1\t0",
187+
"git status --porcelain": "",
188+
});
189+
190+
await gitPlugin.executeStage(context, DefaultStages.check);
191+
expect(context.errors).toHaveLength(0);
192+
});
193+
194+
it("falls back to main/master when git symbolic-ref fails", async () => {
195+
const gitPlugin = new GitPlugin();
196+
const context = createMockContext({
197+
plugins: [gitPlugin],
198+
});
199+
context.sys.mockExec((cmd) => {
200+
if (cmd === "git config --get user.name") return "henlo";
201+
if (cmd === "git config --get user.email") return "this.is@dog";
202+
if (cmd === "git rev-parse --abbrev-ref HEAD") return "main";
203+
if (cmd === "git symbolic-ref --short refs/remotes/origin/HEAD") {
204+
throw new Error("not a symbolic ref");
205+
}
206+
if (cmd === "git rev-list --left-right --count HEAD...origin") return "1\t0";
207+
if (cmd === "git status --porcelain") return "";
208+
throw new Error(`mock missing for command "${cmd}"!`);
209+
});
210+
211+
await gitPlugin.executeStage(context, DefaultStages.check);
212+
expect(context.errors).toHaveLength(0);
213+
});
214+
215+
it("succeeds with custom branch pattern (single string)", async () => {
216+
const gitPlugin = new GitPlugin();
217+
const context = createMockContext({
218+
plugins: [gitPlugin],
219+
argv: { branchPattern: ["develop"] },
220+
});
221+
context.sys.mockExec({
222+
"git config --get user.name": "henlo",
223+
"git config --get user.email": "this.is@dog",
224+
"git rev-parse --abbrev-ref HEAD": "develop",
225+
"git rev-list --left-right --count HEAD...origin": "1\t0",
226+
"git status --porcelain": "",
227+
});
228+
229+
await gitPlugin.executeStage(context, DefaultStages.check);
230+
expect(context.errors).toHaveLength(0);
231+
});
232+
233+
it("succeeds with wildcard pattern matching release branches", async () => {
234+
const gitPlugin = new GitPlugin();
235+
const context = createMockContext({
236+
plugins: [gitPlugin],
237+
argv: { branchPattern: ["main", "release/*"] },
238+
});
239+
context.sys.mockExec({
240+
"git config --get user.name": "henlo",
241+
"git config --get user.email": "this.is@dog",
242+
"git rev-parse --abbrev-ref HEAD": "release/1.0.0",
243+
"git rev-list --left-right --count HEAD...origin": "1\t0",
244+
"git status --porcelain": "",
245+
});
246+
247+
await gitPlugin.executeStage(context, DefaultStages.check);
248+
expect(context.errors).toHaveLength(0);
249+
});
250+
251+
it("fails with wildcard pattern when branch doesn't match", async () => {
252+
const gitPlugin = new GitPlugin();
253+
const context = createMockContext({
254+
plugins: [gitPlugin],
255+
argv: { branchPattern: ["main", "release/*"] },
256+
});
257+
context.sys.mockExec({
258+
"git config --get user.name": "henlo",
259+
"git config --get user.email": "this.is@dog",
260+
"git rev-parse --abbrev-ref HEAD": "feature/my-feature",
261+
});
262+
263+
await assertReleaseError(() => gitPlugin.executeStage(context, DefaultStages.check), {
264+
fatal: true,
265+
messageMatches: /Release can only be triggered from/i,
266+
});
267+
});
110268
});
111269

112270
describe("commit stage", () => {

packages/plugin-git/src/index.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ async function hasGitIdentity(context: Context): Promise<boolean> {
2121
}
2222
}
2323

24+
async function getCurrentBranch(context: Context): Promise<string> {
25+
const { stdout: branch } = await context.sys.execRaw("git rev-parse --abbrev-ref HEAD", {
26+
cwd: context.cwd,
27+
});
28+
return branch;
29+
}
30+
31+
async function getDefaultBranch(context: Context): Promise<string | undefined> {
32+
try {
33+
const { stdout } = await context.sys.execRaw(
34+
"git symbolic-ref --short refs/remotes/origin/HEAD",
35+
{ cwd: context.cwd },
36+
);
37+
// Output is like "origin/main", extract the branch name
38+
const match = stdout.match(/^origin\/(.+)$/);
39+
return match?.[1];
40+
} catch {
41+
return undefined;
42+
}
43+
}
44+
2445
async function getUpstream(context: Context): Promise<string> {
2546
const { stdout: upstream } = await context.sys.execRaw(
2647
"git rev-parse --abbrev-ref --symbolic-full-name @{u}",
@@ -67,6 +88,51 @@ async function gitStatus(context: Context): Promise<GitStatus> {
6788
}
6889
}
6990

91+
function matchesBranchPattern(branch: string, pattern: string): boolean {
92+
// Support only * as a wildcard that matches 1 or more characters
93+
// Escape all regex special characters except *
94+
const regexPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".+");
95+
96+
// Anchor the pattern to match the entire branch name
97+
const anchored = `^${regexPattern}$`;
98+
99+
try {
100+
return new RegExp(anchored).test(branch);
101+
} catch {
102+
// If regex is invalid, treat as literal string match
103+
return branch === pattern;
104+
}
105+
}
106+
107+
async function checkBranchPattern(context: Context, currentBranch: string): Promise<void> {
108+
const patterns = context.argv.branchPattern as string | string[] | undefined;
109+
110+
let branchPatterns: string[];
111+
if (patterns) {
112+
branchPatterns = Array.isArray(patterns) ? patterns : [patterns];
113+
} else {
114+
// If the user did not specify any patterns, default to the repository's default branch
115+
const defaultBranch = await getDefaultBranch(context);
116+
if (defaultBranch) {
117+
branchPatterns = [defaultBranch];
118+
} else {
119+
// Fallback to common defaults if we can't determine the default branch
120+
branchPatterns = ["main", "master"];
121+
}
122+
}
123+
124+
const matches = branchPatterns.some((pattern) => matchesBranchPattern(currentBranch, pattern));
125+
126+
if (!matches) {
127+
const message = `Release can only be triggered from branches matching:
128+
${context.cli.colors.blue(branchPatterns.map((p) => ${p}`).join("\n"))}
129+
Current branch: ${context.cli.colors.red(currentBranch)}
130+
131+
Note: Use the --branch-pattern option to customize this behavior.`;
132+
context.cli.fatal(message);
133+
}
134+
}
135+
70136
class GitPlugin implements Plugin {
71137
public readonly id = "git";
72138
public readonly stages = [
@@ -103,6 +169,12 @@ class GitPlugin implements Plugin {
103169
description: "Do not push anything to the remote",
104170
default: false,
105171
},
172+
branchPattern: {
173+
type: "string",
174+
array: true,
175+
description:
176+
"On which git branches a release may be created. Supports '*' as a wildcard. Defaults to the repository's default branch, or ['main', 'master'] if it cannot be determined.",
177+
},
106178
});
107179
}
108180

@@ -128,6 +200,10 @@ Note: If the current folder belongs to a different user than ${colors.bold(
128200
context.cli.fatal(message);
129201
}
130202

203+
// Check that we're on the correct branch
204+
const currentBranch = await getCurrentBranch(context);
205+
await checkBranchPattern(context, currentBranch);
206+
131207
const lerna = context.hasData("lerna") && !!context.getData("lerna");
132208

133209
// check if there are untracked changes

0 commit comments

Comments
 (0)