diff --git a/packages/core/src/project/monorepo.ts b/packages/core/src/project/monorepo.ts index c619287..15d93c4 100644 --- a/packages/core/src/project/monorepo.ts +++ b/packages/core/src/project/monorepo.ts @@ -9,6 +9,8 @@ import type { } from './monorepo.types.js' import { type ProjectOptions, + type ProjectMaintenanceBranch, + type ProjectReleaseOptions, type ProjectRevertOptions, Project, bumpDefaultOptions @@ -196,6 +198,33 @@ export abstract class MonorepoProject extends Project { return this.getIndependentReleaseData() } + private async getIndependentMaintenanceBranches() { + const branches: ProjectMaintenanceBranch[] = [] + + for await (const project of this.getProjects()) { + const { manifest } = project + const name = await manifest.getName() + const scope = await this.getScope(name) + const tagPrefix = await this.getTagPrefix(scope) + + branches.push(...await project.getMaintenanceBranches({ + tagPrefix + })) + } + + return branches + } + + override async getMaintenanceBranches(options: ProjectReleaseOptions = {}) { + const { mode } = this + + if (mode === 'fixed') { + return super.getMaintenanceBranches(options) + } + + return this.getIndependentMaintenanceBranches() + } + private async getBumpOptions( project: Project, options: MonorepoProjectBumpOptions, diff --git a/packages/core/src/project/packageJson.spec.ts b/packages/core/src/project/packageJson.spec.ts index 37b197f..9880032 100644 --- a/packages/core/src/project/packageJson.spec.ts +++ b/packages/core/src/project/packageJson.spec.ts @@ -65,6 +65,33 @@ describe('core', () => { expect(release).toEqual([]) }) + it('should get maintenance branch refs', async () => { + const { cwd } = await packageJsonProject({ + version: '3.0.0' + }) + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + + expect(await project.getMaintenanceBranches()).toEqual([ + { + from: 'v2.0.0', + to: 'v2' + } + ]) + }) + + it('should not get maintenance branch refs for non-major releases', async () => { + const { cwd } = await packageJsonProject({ + version: '2.1.0' + }) + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + + expect(await project.getMaintenanceBranches()).toEqual([]) + }) + it('should get next version', async () => { const { cwd } = await packageJsonProject() const project = new PackageJsonProject({ diff --git a/packages/core/src/project/packageJsonMonorepo.spec.ts b/packages/core/src/project/packageJsonMonorepo.spec.ts index c1f9e2f..987a34e 100644 --- a/packages/core/src/project/packageJsonMonorepo.spec.ts +++ b/packages/core/src/project/packageJsonMonorepo.spec.ts @@ -137,6 +137,40 @@ describe('core', () => { ]) }) + it('should get maintenance branch refs', async () => { + const { cwd } = await packageJsonIndependentMonorepoProject({ + 1: { + version: '3.0.0' + }, + 2: { + version: '3.0.0' + }, + 3: { + version: '3.0.0' + } + }) + const project = new PackageJsonMonorepoProject({ + mode: 'independent', + root: cwd, + getProjects + }) + + expect(await project.getMaintenanceBranches()).toEqual([ + { + from: 'subproject-1@2.0.0', + to: 'subproject-1@2' + }, + { + from: 'subproject-2@2.0.0', + to: 'subproject-2@2' + }, + { + from: 'subproject-3@2.0.0', + to: 'subproject-3@2' + } + ]) + }) + it('should bump version', async () => { const { cwd } = await forkProject('bump', packageJsonIndependentMonorepoProject()) const project = new PackageJsonMonorepoProject({ @@ -272,6 +306,26 @@ describe('core', () => { expect(release).toEqual([]) }) + it('should get maintenance branch refs', async () => { + const { cwd } = await packageJsonFixedMonorepoProject({ + 0: { + version: '3.0.0' + } + }) + const project = new PackageJsonMonorepoProject({ + mode: 'fixed', + root: cwd, + getProjects + }) + + expect(await project.getMaintenanceBranches()).toEqual([ + { + from: 'v2.0.0', + to: 'v2' + } + ]) + }) + it('should bump version', async () => { const { cwd } = await forkProject('bump', packageJsonFixedMonorepoProject()) const project = new PackageJsonMonorepoProject({ diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts index 00c68d2..e8a2f5d 100644 --- a/packages/core/src/project/project.ts +++ b/packages/core/src/project/project.ts @@ -24,6 +24,7 @@ import type { ProjectVersionUpdate, ProjectTagsOptions, ProjectReleaseOptions, + ProjectMaintenanceBranch, ProjectPublishOptions, ProjectRevertOptions } from './project.types.js' @@ -174,6 +175,51 @@ export abstract class Project { ] } + /** + * Get maintenance branch refs to create after a major version release. + * @param options - The options to use for detecting tags and formatting branches. + * @returns Maintenance branch refs. + */ + async getMaintenanceBranches(options: ProjectReleaseOptions = {}): Promise { + const { + gitClient, + manifest + } = this + const { tagPrefix = 'v' } = options + const { projectPath } = manifest + const version = await manifest.getVersion() + const currentVersion = semver.valid(version) + + if (!currentVersion) { + return [] + } + + const [release] = await this.getReleaseData(options) + const previousTag = release?.previousTag || await gitClient.getLastSemverTag({ + path: projectPath, + prefix: tagPrefix + }) + + if (previousTag) { + const previousVersion = semver.valid( + previousTag.startsWith(tagPrefix) + ? previousTag.slice(tagPrefix.length) + : previousTag + ) + + if (previousVersion && semver.major(currentVersion) > semver.major(previousVersion)) { + return [ + { + from: previousTag, + to: `${tagPrefix}${semver.major(previousVersion)}` + } + ] + } + } + + return [] + } + /** * Get the next version for the project. * @param options - The options to use for getting the next version. diff --git a/packages/core/src/project/project.types.ts b/packages/core/src/project/project.types.ts index 2d1b80e..f228a1f 100644 --- a/packages/core/src/project/project.types.ts +++ b/packages/core/src/project/project.types.ts @@ -109,6 +109,17 @@ export interface ProjectReleaseOptions { tagPrefix?: string } +export interface ProjectMaintenanceBranch { + /** + * Source tag to create the branch from. + */ + from: string + /** + * Target branch name to create. + */ + to: string +} + export interface ProjectPublishOptions { dryRun?: boolean logger?: ChildLogger diff --git a/packages/core/src/releaser.spec.ts b/packages/core/src/releaser.spec.ts index 8abb465..0ea34f0 100644 --- a/packages/core/src/releaser.spec.ts +++ b/packages/core/src/releaser.spec.ts @@ -5,8 +5,10 @@ import { it, expect } from 'vitest' +import { GitClient } from '@conventional-changelog/git-client' import { firstFromStream } from '@simple-libs/stream-utils' import { + createDirectory, forkProject, packageJsonProject } from 'test' @@ -72,5 +74,79 @@ describe('core', () => { expect(project.changedFiles).toEqual([]) expect(project.versionUpdates).toEqual([]) }) + + it('should create maintenance branches', async () => { + const { cwd } = await forkProject('maintenance-branch', packageJsonProject({ + version: '3.0.0' + })) + const remote = await createDirectory('maintenance-branch-remote') + const remoteGit = new GitClient(remote) + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + const releaser = new Releaser({ + project, + silent: true + }) + .maintenanceBranch({ + enabled: true + }) + + await remoteGit.exec('init', '--bare') + await project.gitClient.exec('remote', 'set-url', 'origin', remote) + await releaser.run() + + expect(await project.gitClient.verify('refs/heads/v2', true)).not.toBe('') + expect(await project.gitClient.exec('ls-remote', '--heads', 'origin', 'v2')).toContain('refs/heads/v2') + }) + + it('should skip existing remote maintenance branches', async () => { + const { cwd } = await forkProject('maintenance-branch-remote', packageJsonProject({ + version: '3.0.0' + })) + const remote = await createDirectory('maintenance-branch-existing-remote') + const remoteGit = new GitClient(remote) + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + const releaser = new Releaser({ + project, + silent: true + }) + .maintenanceBranch({ + enabled: true + }) + + await remoteGit.exec('init', '--bare') + await project.gitClient.exec('remote', 'set-url', 'origin', remote) + await project.gitClient.exec('branch', '--', 'v2', 'v2.0.0') + await project.gitClient.push('v2', { + verify: false + }) + await project.gitClient.deleteBranch('v2') + + await releaser.run() + + expect(await project.gitClient.verify('refs/heads/v2', true)).toBe('') + expect(await project.gitClient.exec('ls-remote', '--heads', 'origin', 'v2')).toContain('refs/heads/v2') + }) + + it('should not create maintenance branches by default', async () => { + const { cwd } = await forkProject('maintenance-branch-disabled', packageJsonProject({ + version: '3.0.0' + })) + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + const releaser = new Releaser({ + project, + silent: true + }) + .maintenanceBranch() + + await releaser.run() + + expect(await project.gitClient.verify('refs/heads/v2', true)).toBe('') + }) }) }) diff --git a/packages/core/src/releaser.ts b/packages/core/src/releaser.ts index 1c9765e..9c12e49 100644 --- a/packages/core/src/releaser.ts +++ b/packages/core/src/releaser.ts @@ -6,6 +6,7 @@ import type { ReleaserCheckoutOptions, ReleaserOptions, ReleaserCommitOptions, + ReleaserMaintenanceBranchOptions, ReleaserTagOptions, ReleaserPushOptions, ReleaserStepsOptions @@ -298,6 +299,78 @@ export class Releaser< }) } + /** + * Enqueue a task to create maintenance branches. + * @param options + * @returns Project releaser instance for chaining. + */ + maintenanceBranch(options?: ReleaserMaintenanceBranchOptions) { + return this.enqueue(async () => { + const { + project, + logger, + gitClient + } = this + const { dryRun } = this.options + const { + enabled, + force + } = { + ...this.stepsOptions.maintenanceBranch, + ...options + } + + logger.info('maintenance-branch', 'Creating maintenance branches...') + + if (!enabled) { + logger.info('maintenance-branch', 'Maintenance branch creation is disabled.') + return + } + + logger.verbose('maintenance-branch', 'Fetching fresh tags from the remote repository...') + + if (!dryRun) { + await gitClient.fetch({ + tags: true + }) + } + + const branches = await project.getMaintenanceBranches() + + if (!branches.length) { + logger.info('maintenance-branch', 'No maintenance branches to create.') + return + } + + logger.verbose('maintenance-branch', 'Branches to create:') + + for (const { + from, + to + } of branches) { + logger.verbose('maintenance-branch', `- ${to} from ${from}`) + + if (dryRun) { + continue + } + + const branchExists = await gitClient.verify(`refs/heads/${to}`, true) + || await gitClient.exec('ls-remote', '--heads', 'origin', to) + + if (branchExists && !force) { + logger.info('maintenance-branch', `Branch ${to} already exists, skipping.`) + continue + } + + await gitClient.exec('branch', force && '-f', '--', to, from) + await gitClient.push(to, { + verify: false, + force + }) + } + }) + } + /** * Enqueue a task to tag the project with the new version. * @param options diff --git a/packages/core/src/releaser.types.ts b/packages/core/src/releaser.types.ts index d7b84bf..e1852fe 100644 --- a/packages/core/src/releaser.types.ts +++ b/packages/core/src/releaser.types.ts @@ -61,6 +61,13 @@ export interface ReleaserTagOptions extends Omit { + /** + * Enable maintenance branch creation. + */ + enabled?: boolean +} + export type ReleaserPushOptions = Omit export interface ReleaserStepsOptions< @@ -72,6 +79,7 @@ export interface ReleaserStepsOptions< } & ReleaserCheckoutOptions bump?: PickOverridableOptions commit?: ReleaserCommitOptions + maintenanceBranch?: ReleaserMaintenanceBranchOptions tag?: ReleaserTagOptions push?: ReleaserPushOptions publish?: PickOverridableOptions diff --git a/packages/github-action/src/options.ts b/packages/github-action/src/options.ts index 2543696..b733546 100644 --- a/packages/github-action/src/options.ts +++ b/packages/github-action/src/options.ts @@ -45,6 +45,9 @@ export function getInputOptions() { skip: getOptionalBooleanInput('bump-skip'), byProject: getOptionalJsonInput('bump-by-project') }), + maintenanceBranch: omitEmpty({ + enabled: getOptionalBooleanInput('maintenance-branch') + }), publish: omitEmpty({ skip: getOptionalBooleanInput('publish-skip'), access: getInput('publish-access'), diff --git a/packages/github-action/src/releaser.ts b/packages/github-action/src/releaser.ts index 7ccec6b..9dd50ff 100644 --- a/packages/github-action/src/releaser.ts +++ b/packages/github-action/src/releaser.ts @@ -137,6 +137,7 @@ export class ReleaserGithubAction

extends Releaser< */ async runReleaseAction(check = true) { await this + .maintenanceBranch() .tag() .push() .release()