diff --git a/common/changes/@coze-arch/rush-publish-plugin/feat-allow-branches-pattern-matching_2026-03-19-09-56.json b/common/changes/@coze-arch/rush-publish-plugin/feat-allow-branches-pattern-matching_2026-03-19-09-56.json new file mode 100644 index 00000000..9d6c8065 --- /dev/null +++ b/common/changes/@coze-arch/rush-publish-plugin/feat-allow-branches-pattern-matching_2026-03-19-09-56.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-arch/rush-publish-plugin", + "comment": "support glob and regex patterns for branch validation", + "type": "minor" + } + ], + "packageName": "@coze-arch/rush-publish-plugin", + "email": "tecvan.fe@qq.com" +} diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 5a6dbd19..66bd0156 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -1098,6 +1098,9 @@ importers: dayjs: specifier: ^1.11.13 version: 1.11.13 + minimatch: + specifier: ^10.0.0 + version: 10.1.2 open: specifier: ~10.1.0 version: 10.1.0 diff --git a/packages/rush-plugins/publish/__tests__/actions/publish/action.test.ts b/packages/rush-plugins/publish/__tests__/actions/publish/action.test.ts index 3771cb86..cc4491e5 100644 --- a/packages/rush-plugins/publish/__tests__/actions/publish/action.test.ts +++ b/packages/rush-plugins/publish/__tests__/actions/publish/action.test.ts @@ -9,7 +9,7 @@ import { import { logger } from '@coze-arch/logger'; import { randomHash } from '@/utils/random'; -import { ensureNotUncommittedChanges, isMainBranch } from '@/utils/git'; +import { ensureNotUncommittedChanges } from '@/utils/git'; import { getRushConfiguration } from '@/utils/get-rush-config'; import { release } from '@/action/release/action'; import { generatePublishManifest } from '@/action/publish/version'; @@ -105,7 +105,6 @@ describe('publish action', () => { vi.mocked(confirmForPublish).mockResolvedValue(true); vi.mocked(applyPublishManifest).mockResolvedValue(['package.json']); vi.mocked(generateChangelog).mockResolvedValue(['CHANGELOG.md']); - vi.mocked(isMainBranch).mockResolvedValue(true); }); it('should publish packages successfully', async () => { @@ -177,68 +176,6 @@ describe('publish action', () => { expect(generatePublishManifest).not.toHaveBeenCalled(); }); - it('should stop if not in main branch for production release', async () => { - vi.mocked(isMainBranch).mockResolvedValue(false); - delete process.env.SKIP_BRANCH_CHECK; - await publish({ - to: ['package-1'], - repoUrl: 'git@github.com:example/repo.git', - }); - expect(logger.error).toHaveBeenCalledWith( - 'You are not in main branch, please switch to main branch and try again.', - ); - expect(applyPublishManifest).not.toHaveBeenCalled(); - }); - - it('should skip branch check when SKIP_BRANCH_CHECK is true', async () => { - vi.mocked(isMainBranch).mockResolvedValue(false); - process.env.SKIP_BRANCH_CHECK = 'true'; - - await publish({ - to: ['package-1'], - repoUrl: 'git@github.com:example/repo.git', - }); - - expect(logger.error).not.toHaveBeenCalledWith( - 'You are not in main branch, please switch to main branch and try again.', - ); - expect(applyPublishManifest).toHaveBeenCalled(); - - delete process.env.SKIP_BRANCH_CHECK; - }); - - it('should allow non-main branch for beta releases', async () => { - vi.mocked(isMainBranch).mockResolvedValue(false); - vi.mocked(generatePublishManifest).mockResolvedValue({ - manifests: mockPublishManifests, - bumpPolicy: BumpType.BETA, - }); - - await publish({ - to: ['package-1'], - repoUrl: 'git@github.com:example/repo.git', - }); - - expect(logger.error).not.toHaveBeenCalled(); - expect(applyPublishManifest).toHaveBeenCalled(); - }); - - it('should allow non-main branch for alpha releases', async () => { - vi.mocked(isMainBranch).mockResolvedValue(false); - vi.mocked(generatePublishManifest).mockResolvedValue({ - manifests: mockPublishManifests, - bumpPolicy: BumpType.ALPHA, - }); - - await publish({ - to: ['package-1'], - repoUrl: 'git@github.com:example/repo.git', - }); - - expect(logger.error).not.toHaveBeenCalled(); - expect(applyPublishManifest).toHaveBeenCalled(); - }); - it('should stop if user does not confirm', async () => { vi.mocked(confirmForPublish).mockResolvedValue(false); await publish({ diff --git a/packages/rush-plugins/publish/__tests__/actions/release/plan.test.ts b/packages/rush-plugins/publish/__tests__/actions/release/plan.test.ts index 5d495398..a2bd8560 100644 --- a/packages/rush-plugins/publish/__tests__/actions/release/plan.test.ts +++ b/packages/rush-plugins/publish/__tests__/actions/release/plan.test.ts @@ -229,5 +229,281 @@ describe('plan', () => { checkReleasePlan(releaseManifests, 'any-branch', []), ).toThrow('For LATEST release, should be on one of these branches: .'); }); + + describe('glob pattern matching', () => { + const releaseManifests: ReleaseManifest[] = [ + { + project: mockProject1, + version: '1.0.0', + }, + ]; + + it('should match branches with wildcard pattern', () => { + const allowBranches = [ + 'chore/*', + 'release/*', + 'integration/*', + 'master', + ]; + + // Should match + expect(() => + checkReleasePlan( + releaseManifests, + 'chore/upgrade-version', + allowBranches, + ), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'chore/fix-bug', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'release/v1.0.0', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'integration/test', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'master', allowBranches), + ).not.toThrow(); + + // Should not match + expect(() => + checkReleasePlan(releaseManifests, 'feature/new', allowBranches), + ).toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'main', allowBranches), + ).toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'chore', allowBranches), + ).toThrow(); + }); + + it('should match branches with double-star pattern', () => { + const allowBranches = ['release/**']; + + // Should match + expect(() => + checkReleasePlan(releaseManifests, 'release/v1.0.0', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan( + releaseManifests, + 'release/2024/v1.0.0', + allowBranches, + ), + ).not.toThrow(); + expect(() => + checkReleasePlan( + releaseManifests, + 'release/prod/v1.0.0', + allowBranches, + ), + ).not.toThrow(); + + // Should not match + expect(() => + checkReleasePlan(releaseManifests, 'chore/fix', allowBranches), + ).toThrow(); + }); + + it('should match branches with prefix pattern', () => { + const allowBranches = ['feat-*', 'hotfix-*']; + + // Should match + expect(() => + checkReleasePlan(releaseManifests, 'feat-123', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'hotfix-urgent', allowBranches), + ).not.toThrow(); + + // Should not match + expect(() => + checkReleasePlan(releaseManifests, 'feat/123', allowBranches), + ).toThrow(); + }); + + it('should match branches with suffix pattern', () => { + const allowBranches = ['*-prod', '*-staging']; + + // Should match + expect(() => + checkReleasePlan(releaseManifests, 'release-prod', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'deploy-staging', allowBranches), + ).not.toThrow(); + + // Should not match + expect(() => + checkReleasePlan(releaseManifests, 'release-dev', allowBranches), + ).toThrow(); + }); + }); + + describe('regex pattern matching', () => { + const releaseManifests: ReleaseManifest[] = [ + { + project: mockProject1, + version: '1.0.0', + }, + ]; + + it('should match branches with regex pattern', () => { + const allowBranches = ['/^(main|master|develop)$/']; + + // Should match + expect(() => + checkReleasePlan(releaseManifests, 'main', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'master', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'develop', allowBranches), + ).not.toThrow(); + + // Should not match + expect(() => + checkReleasePlan(releaseManifests, 'main-backup', allowBranches), + ).toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'feature/test', allowBranches), + ).toThrow(); + }); + + it('should match branches with version number regex', () => { + const allowBranches = ['/^release\\/v\\d+\\.\\d+\\.\\d+$/']; + + // Should match + expect(() => + checkReleasePlan(releaseManifests, 'release/v1.0.0', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'release/v2.10.5', allowBranches), + ).not.toThrow(); + + // Should not match + expect(() => + checkReleasePlan(releaseManifests, 'release/v1', allowBranches), + ).toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'release/v1.0', allowBranches), + ).toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'chore/v1.0.0', allowBranches), + ).toThrow(); + }); + + it('should handle invalid regex gracefully', () => { + const allowBranches = ['/^(invalid/']; + + // Should fall back to exact match + expect(() => + checkReleasePlan(releaseManifests, '/^(invalid/', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'main', allowBranches), + ).toThrow(); + }); + }); + + describe('mixed pattern matching', () => { + const releaseManifests: ReleaseManifest[] = [ + { + project: mockProject1, + version: '1.0.0', + }, + ]; + + it('should support mix of exact, glob, and regex patterns', () => { + const allowBranches = [ + 'main', // exact match + 'chore/*', // glob pattern + '/^release\\/v\\d+\\.\\d+\\.\\d+$/', // regex pattern + ]; + + // Should match exact + expect(() => + checkReleasePlan(releaseManifests, 'main', allowBranches), + ).not.toThrow(); + + // Should match glob + expect(() => + checkReleasePlan(releaseManifests, 'chore/fix-bug', allowBranches), + ).not.toThrow(); + + // Should match regex + expect(() => + checkReleasePlan(releaseManifests, 'release/v1.0.0', allowBranches), + ).not.toThrow(); + + // Should not match + expect(() => + checkReleasePlan(releaseManifests, 'feature/test', allowBranches), + ).toThrow(); + }); + }); + + describe('edge cases', () => { + const releaseManifests: ReleaseManifest[] = [ + { + project: mockProject1, + version: '1.0.0', + }, + ]; + + it('should handle branches with special characters', () => { + const allowBranches = ['feat/*']; + + expect(() => + checkReleasePlan( + releaseManifests, + 'feat/user-profile', + allowBranches, + ), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'feat/issue-#123', allowBranches), + ).not.toThrow(); + }); + + it('should handle branches with multiple slashes', () => { + const allowBranches = ['team/**']; + + expect(() => + checkReleasePlan( + releaseManifests, + 'team/frontend/feature', + allowBranches, + ), + ).not.toThrow(); + expect(() => + checkReleasePlan( + releaseManifests, + 'team/backend/fix/urgent', + allowBranches, + ), + ).not.toThrow(); + }); + + it('should be case-sensitive by default', () => { + const allowBranches = ['Main', 'CHORE/*']; + + expect(() => + checkReleasePlan(releaseManifests, 'Main', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'main', allowBranches), + ).toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'CHORE/fix', allowBranches), + ).not.toThrow(); + expect(() => + checkReleasePlan(releaseManifests, 'chore/fix', allowBranches), + ).toThrow(); + }); + }); }); }); diff --git a/packages/rush-plugins/publish/package.json b/packages/rush-plugins/publish/package.json index 7a35e80b..ffa1c512 100644 --- a/packages/rush-plugins/publish/package.json +++ b/packages/rush-plugins/publish/package.json @@ -1,6 +1,6 @@ { "name": "@coze-arch/rush-publish-plugin", - "version": "0.0.5-beta.2", + "version": "0.0.5-alpha.6708a5", "description": "rush plugin to generate change log and publish packages", "keywords": [ "rush", @@ -12,9 +12,10 @@ "license": "MIT", "author": "tecvan.fe@gmail.com", "maintainers": [], - "main": "src/index.ts", + "main": "./lib/index.js", + "types": "lib/index.d.ts", "bin": { - "rush-publish": "./src/run.js" + "rush-publish": "./lib/index.js" }, "files": [ "lib", @@ -36,6 +37,7 @@ "conventional-changelog-angular": "^5.0.13", "conventional-commits-parser": "^3.2.4", "dayjs": "^1.11.13", + "minimatch": "^10.0.0", "open": "~10.1.0", "semver": "^7.7.1", "shelljs": "^0.9.2" diff --git a/packages/rush-plugins/publish/src/action/publish/action.ts b/packages/rush-plugins/publish/src/action/publish/action.ts index 4a7833e7..2eb1bcbd 100644 --- a/packages/rush-plugins/publish/src/action/publish/action.ts +++ b/packages/rush-plugins/publish/src/action/publish/action.ts @@ -5,7 +5,7 @@ import { logger } from '@coze-arch/logger'; import { release } from '../release/action'; import { randomHash } from '../../utils/random'; -import { ensureNotUncommittedChanges, isMainBranch } from '../../utils/git'; +import { ensureNotUncommittedChanges } from '../../utils/git'; import { getRushConfiguration } from '../../utils/get-rush-config'; import { generatePublishManifest } from './version'; import { BumpType, type PublishOptions } from './types'; @@ -54,17 +54,6 @@ export const publish = async (options: PublishOptions) => { const isBetaPublish = [BumpType.BETA, BumpType.ALPHA].includes( bumpPolicy as BumpType, ); - if ( - process.env.SKIP_BRANCH_CHECK !== 'true' && - isBetaPublish === false && - (await isMainBranch()) === false - ) { - // 只允许在主分支发布 - logger.error( - 'You are not in main branch, please switch to main branch and try again.', - ); - return; - } const continuePublish = await confirmForPublish( publishManifests, diff --git a/packages/rush-plugins/publish/src/action/release/plan.ts b/packages/rush-plugins/publish/src/action/release/plan.ts index 2eea2d2c..7bb8bb6b 100644 --- a/packages/rush-plugins/publish/src/action/release/plan.ts +++ b/packages/rush-plugins/publish/src/action/release/plan.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025 coze-dev // SPDX-License-Identifier: MIT +import { minimatch } from 'minimatch'; + import { type ReleaseManifest } from './types'; export enum ReleaseType { @@ -29,6 +31,36 @@ const calReleasePlan = (releaseManifests: ReleaseManifest[]) => { return ReleaseType.ALPHA; }; +/** + * Check if a branch name matches any of the allowed patterns. + * Supports exact matches, glob patterns (using minimatch), and regex patterns. + * + * @param branchName - The current branch name + * @param allowPatterns - Array of patterns (exact strings, glob patterns, or regex strings starting with '/') + * @returns true if the branch matches any pattern, false otherwise + */ +const isBranchAllowed = ( + branchName: string, + allowPatterns: string[], +): boolean => + allowPatterns.some(pattern => { + // Check if it's a regex pattern (enclosed in forward slashes) + if (pattern.startsWith('/') && pattern.endsWith('/')) { + const regexPattern = pattern.slice(1, -1); + try { + // eslint-disable-next-line security/detect-non-literal-regexp -- User-provided patterns are validated + const regex = new RegExp(regexPattern); + return regex.test(branchName); + } catch { + // Invalid regex, fall back to exact match + return pattern === branchName; + } + } + + // Use minimatch for glob patterns and exact matches + return minimatch(branchName, pattern); + }); + export const checkReleasePlan = ( releaseManifests: ReleaseManifest[], branchName: string, @@ -37,7 +69,7 @@ export const checkReleasePlan = ( const releasePlan = calReleasePlan(releaseManifests); if ( releasePlan === ReleaseType.LATEST && - !allowBranches.includes(branchName) + !isBranchAllowed(branchName, allowBranches) ) { throw new Error( `For LATEST release, should be on one of these branches: ${allowBranches.join(', ')}. Current Branch: ${branchName}`,