Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 27 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@

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

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

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.

Expand Down Expand Up @@ -72,8 +72,8 @@ In order to use this script, you need to maintain the changelog in either `READM

## **WORK IN PROGRESS**

- Did some changes
- Did some more changes
- Did some changes
- Did some more changes

## v0.0.1 (2020-01-01)

Expand Down Expand Up @@ -166,8 +166,8 @@ Instead of manually providing all options, you can configure the release process
"plugins": ["iobroker", "lerna"],
// Objects
"exec": {
"before_commit": "echo Hello World!"
}
"before_commit": "echo Hello World!",
},
}
```

Expand Down Expand Up @@ -291,7 +291,7 @@ or to `.releaseconfig.json` if you're using that:
```jsonc
{
// ... other options
"plugins": ["iobroker", "license"]
"plugins": ["iobroker", "license"],
}
```

Expand Down Expand Up @@ -342,6 +342,14 @@ When this option is set, nothing will be pushed to the remote. This option can b
npm run release patch -- --noPush
```

#### Configure branches from which releases can be created (`--branchPattern`)

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.

```bash
npm run release patch -- --branchPattern main --branchPattern release/*
```

### `changelog` plugin options

#### Limit the number of entries in README.md (`--numChangelogEntries` or `-n`)
Expand Down Expand Up @@ -402,8 +410,8 @@ To run custom scripts, you can use these options. They accept an object defining
// Run "echo 1", "echo 2", "echo 3" after the "push" stage
"after_push": ["echo 1", "echo 2", "echo 3"],
// Run "sudo shutdown" before the "check" stage
"before_check": "sudo shutdown"
}
"before_check": "sudo shutdown",
},
}
```

Expand Down
158 changes: 158 additions & 0 deletions packages/plugin-git/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe("Git plugin", () => {
context.sys.mockExec({
"git config --get user.name": "",
"git config --get user.email": "",
"git rev-parse --abbrev-ref HEAD": "main",
});

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

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

Expand All @@ -66,6 +69,7 @@ describe("Git plugin", () => {
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "main",
"git rev-list --left-right --count HEAD...origin": "1\t0",
"git status --porcelain": "whatever",
});
Expand All @@ -84,6 +88,7 @@ describe("Git plugin", () => {
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "main",
"git rev-list --left-right --count HEAD...origin": "1\t0",
"git status --porcelain": "whatever",
});
Expand All @@ -100,13 +105,166 @@ describe("Git plugin", () => {
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "main",
"git symbolic-ref --short refs/remotes/origin/HEAD": "origin/main",
"git rev-list --left-right --count HEAD...origin": "1\t0",
"git status --porcelain": "",
});

await gitPlugin.executeStage(context, DefaultStages.check);
expect(context.errors).toHaveLength(0);
});

it("succeeds when on the 'main' branch (default pattern)", async () => {
const gitPlugin = new GitPlugin();
const context = createMockContext({
plugins: [gitPlugin],
});
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "main",
"git symbolic-ref --short refs/remotes/origin/HEAD": "origin/main",
"git rev-list --left-right --count HEAD...origin": "1\t0",
"git status --porcelain": "",
});

await gitPlugin.executeStage(context, DefaultStages.check);
expect(context.errors).toHaveLength(0);
});

it("succeeds when on the 'master' branch (default pattern)", async () => {
const gitPlugin = new GitPlugin();
const context = createMockContext({
plugins: [gitPlugin],
});
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "master",
"git symbolic-ref --short refs/remotes/origin/HEAD": "origin/master",
"git rev-list --left-right --count HEAD...origin": "1\t0",
"git status --porcelain": "",
});

await gitPlugin.executeStage(context, DefaultStages.check);
expect(context.errors).toHaveLength(0);
});

it("raises a fatal error when not on an allowed branch", async () => {
const gitPlugin = new GitPlugin();
const context = createMockContext({
plugins: [gitPlugin],
});
context.sys.mockExec((cmd) => {
if (cmd === "git config --get user.name") return "henlo";
if (cmd === "git config --get user.email") return "this.is@dog";
if (cmd === "git rev-parse --abbrev-ref HEAD") return "feature-branch";
if (cmd === "git symbolic-ref --short refs/remotes/origin/HEAD") {
// Simulate failure - fall back to ["main", "master"]
throw new Error("not a symbolic ref");
}
throw new Error(`mock missing for command "${cmd}"!`);
});

await assertReleaseError(() => gitPlugin.executeStage(context, DefaultStages.check), {
fatal: true,
messageMatches: /Release can only be triggered from/i,
});
});

it("uses dynamic default branch from git symbolic-ref", async () => {
const gitPlugin = new GitPlugin();
const context = createMockContext({
plugins: [gitPlugin],
});
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "develop",
"git symbolic-ref --short refs/remotes/origin/HEAD": "origin/develop",
"git rev-list --left-right --count HEAD...origin": "1\t0",
"git status --porcelain": "",
});

await gitPlugin.executeStage(context, DefaultStages.check);
expect(context.errors).toHaveLength(0);
});

it("falls back to main/master when git symbolic-ref fails", async () => {
const gitPlugin = new GitPlugin();
const context = createMockContext({
plugins: [gitPlugin],
});
context.sys.mockExec((cmd) => {
if (cmd === "git config --get user.name") return "henlo";
if (cmd === "git config --get user.email") return "this.is@dog";
if (cmd === "git rev-parse --abbrev-ref HEAD") return "main";
if (cmd === "git symbolic-ref --short refs/remotes/origin/HEAD") {
throw new Error("not a symbolic ref");
}
if (cmd === "git rev-list --left-right --count HEAD...origin") return "1\t0";
if (cmd === "git status --porcelain") return "";
throw new Error(`mock missing for command "${cmd}"!`);
});

await gitPlugin.executeStage(context, DefaultStages.check);
expect(context.errors).toHaveLength(0);
});

it("succeeds with custom branch pattern (single string)", async () => {
const gitPlugin = new GitPlugin();
const context = createMockContext({
plugins: [gitPlugin],
argv: { branchPattern: ["develop"] },
});
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "develop",
"git rev-list --left-right --count HEAD...origin": "1\t0",
"git status --porcelain": "",
});

await gitPlugin.executeStage(context, DefaultStages.check);
expect(context.errors).toHaveLength(0);
});

it("succeeds with wildcard pattern matching release branches", async () => {
const gitPlugin = new GitPlugin();
const context = createMockContext({
plugins: [gitPlugin],
argv: { branchPattern: ["main", "release/*"] },
});
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "release/1.0.0",
"git rev-list --left-right --count HEAD...origin": "1\t0",
"git status --porcelain": "",
});

await gitPlugin.executeStage(context, DefaultStages.check);
expect(context.errors).toHaveLength(0);
});

it("fails with wildcard pattern when branch doesn't match", async () => {
const gitPlugin = new GitPlugin();
const context = createMockContext({
plugins: [gitPlugin],
argv: { branchPattern: ["main", "release/*"] },
});
context.sys.mockExec({
"git config --get user.name": "henlo",
"git config --get user.email": "this.is@dog",
"git rev-parse --abbrev-ref HEAD": "feature/my-feature",
});

await assertReleaseError(() => gitPlugin.executeStage(context, DefaultStages.check), {
fatal: true,
messageMatches: /Release can only be triggered from/i,
});
});
});

describe("commit stage", () => {
Expand Down
76 changes: 76 additions & 0 deletions packages/plugin-git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ async function hasGitIdentity(context: Context): Promise<boolean> {
}
}

async function getCurrentBranch(context: Context): Promise<string> {
const { stdout: branch } = await context.sys.execRaw("git rev-parse --abbrev-ref HEAD", {
cwd: context.cwd,
});
return branch;
}

async function getDefaultBranch(context: Context): Promise<string | undefined> {
try {
const { stdout } = await context.sys.execRaw(
"git symbolic-ref --short refs/remotes/origin/HEAD",
{ cwd: context.cwd },
);
// Output is like "origin/main", extract the branch name
const match = stdout.match(/^origin\/(.+)$/);
return match?.[1];
} catch {
return undefined;
}
}

async function getUpstream(context: Context): Promise<string> {
const { stdout: upstream } = await context.sys.execRaw(
"git rev-parse --abbrev-ref --symbolic-full-name @{u}",
Expand Down Expand Up @@ -67,6 +88,51 @@ async function gitStatus(context: Context): Promise<GitStatus> {
}
}

function matchesBranchPattern(branch: string, pattern: string): boolean {
// Support only * as a wildcard that matches 1 or more characters
// Escape all regex special characters except *
const regexPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".+");

// Anchor the pattern to match the entire branch name
const anchored = `^${regexPattern}$`;

try {
return new RegExp(anchored).test(branch);
} catch {
// If regex is invalid, treat as literal string match
return branch === pattern;
}
}

async function checkBranchPattern(context: Context, currentBranch: string): Promise<void> {
const patterns = context.argv.branchPattern as string | string[] | undefined;

let branchPatterns: string[];
if (patterns) {
branchPatterns = Array.isArray(patterns) ? patterns : [patterns];
} else {
// If the user did not specify any patterns, default to the repository's default branch
const defaultBranch = await getDefaultBranch(context);
if (defaultBranch) {
branchPatterns = [defaultBranch];
} else {
// Fallback to common defaults if we can't determine the default branch
branchPatterns = ["main", "master"];
}
}

const matches = branchPatterns.some((pattern) => matchesBranchPattern(currentBranch, pattern));

if (!matches) {
const message = `Release can only be triggered from branches matching:
${context.cli.colors.blue(branchPatterns.map((p) => `· ${p}`).join("\n"))}
Current branch: ${context.cli.colors.red(currentBranch)}

Note: Use the --branch-pattern option to customize this behavior.`;
context.cli.fatal(message);
}
}

class GitPlugin implements Plugin {
public readonly id = "git";
public readonly stages = [
Expand Down Expand Up @@ -103,6 +169,12 @@ class GitPlugin implements Plugin {
description: "Do not push anything to the remote",
default: false,
},
branchPattern: {
type: "string",
array: true,
description:
"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.",
},
});
}

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

// Check that we're on the correct branch
const currentBranch = await getCurrentBranch(context);
await checkBranchPattern(context, currentBranch);

const lerna = context.hasData("lerna") && !!context.getData("lerna");

// check if there are untracked changes
Expand Down