diff --git a/README.md b/README.md index b5f8773..b806d47 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) @@ -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!", + }, } ``` @@ -291,7 +291,7 @@ or to `.releaseconfig.json` if you're using that: ```jsonc { // ... other options - "plugins": ["iobroker", "license"] + "plugins": ["iobroker", "license"], } ``` @@ -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`) @@ -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", + }, } ``` diff --git a/packages/plugin-git/src/index.test.ts b/packages/plugin-git/src/index.test.ts index 22cd62f..0d9a22a 100644 --- a/packages/plugin-git/src/index.test.ts +++ b/packages/plugin-git/src/index.test.ts @@ -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), { @@ -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", }); @@ -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", }); @@ -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", }); @@ -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", }); @@ -100,6 +105,8 @@ 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": "", }); @@ -107,6 +114,157 @@ describe("Git plugin", () => { 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", () => { diff --git a/packages/plugin-git/src/index.ts b/packages/plugin-git/src/index.ts index 238e304..a6e0046 100644 --- a/packages/plugin-git/src/index.ts +++ b/packages/plugin-git/src/index.ts @@ -21,6 +21,27 @@ async function hasGitIdentity(context: Context): Promise { } } +async function getCurrentBranch(context: Context): Promise { + const { stdout: branch } = await context.sys.execRaw("git rev-parse --abbrev-ref HEAD", { + cwd: context.cwd, + }); + return branch; +} + +async function getDefaultBranch(context: Context): Promise { + 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 { const { stdout: upstream } = await context.sys.execRaw( "git rev-parse --abbrev-ref --symbolic-full-name @{u}", @@ -67,6 +88,51 @@ async function gitStatus(context: Context): Promise { } } +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 { + 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 = [ @@ -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.", + }, }); } @@ -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