diff --git a/packages/core/src/project/monorepo.ts b/packages/core/src/project/monorepo.ts index 40f2b26..c619287 100644 --- a/packages/core/src/project/monorepo.ts +++ b/packages/core/src/project/monorepo.ts @@ -9,6 +9,7 @@ import type { } from './monorepo.types.js' import { type ProjectOptions, + type ProjectRevertOptions, Project, bumpDefaultOptions } from './project.js' @@ -341,4 +342,35 @@ export abstract class MonorepoProject extends Project { return this.independentBump(options) } + + override async revert(options: ProjectRevertOptions = {}) { + const { + manifest, + versionUpdates + } = this + const { + dryRun, + logger + } = options + const files = new Set() + const rootUpdate = versionUpdates.find(({ files }) => files.includes(manifest.manifestPath)) + let hasRevert = false + + if (rootUpdate) { + logger?.verbose(`Reverting ${rootUpdate.name}: ${rootUpdate.to} -> ${rootUpdate.from}`) + rootUpdate.files.forEach(file => files.add(file)) + + await manifest.writeVersion(rootUpdate.from, dryRun) + hasRevert = true + } + + for await (const project of this.getProjects()) { + hasRevert = await project.revert(options, files) || hasRevert + } + + this.changedFiles = this.changedFiles.filter(file => !files.has(file)) + this.versionUpdates.length = 0 + + return hasRevert + } } diff --git a/packages/core/src/project/monorepo.types.ts b/packages/core/src/project/monorepo.types.ts index 5a23155..57f6a10 100644 --- a/packages/core/src/project/monorepo.types.ts +++ b/packages/core/src/project/monorepo.types.ts @@ -45,7 +45,7 @@ export interface MonorepoProjectOptions extends ProjectOptions { gitClient?: ConventionalGitClient } -export type MonorepoProjectBumpByProjectOptions = Pick +export type MonorepoProjectBumpByProjectOptions = Pick export interface MonorepoProjectBumpOptions extends Omit { /** diff --git a/packages/core/src/project/packageJson.spec.ts b/packages/core/src/project/packageJson.spec.ts index 6a3a9c9..37b197f 100644 --- a/packages/core/src/project/packageJson.spec.ts +++ b/packages/core/src/project/packageJson.spec.ts @@ -135,6 +135,31 @@ describe('core', () => { expect(version).toBe('2.1.0-alpha.0') }) + it('should get next snapshot version from commits', async () => { + const { cwd } = await packageJsonProject() + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + const version = await project.getNextVersion({ + snapshot: 'snapshot' + }) + + expect(version).toMatch(/^2\.1\.0-snapshot\.\d{14}$/) + }) + + it('should get next snapshot version with given release type', async () => { + const { cwd } = await packageJsonProject() + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + const version = await project.getNextVersion({ + as: 'patch', + snapshot: 'canary' + }) + + expect(version).toMatch(/^2\.0\.1-canary\.\d{14}$/) + }) + it('should dry bump version', async () => { const { cwd } = await packageJsonProject() const project = new PackageJsonProject({ @@ -199,6 +224,27 @@ describe('core', () => { expect(await fs.readFile(join(cwd, 'CHANGELOG.md'), 'utf8')).toContain('Version bump without any changes.') }) + it('should skip changelog generation', async () => { + const { cwd } = await forkProject( + 'bump-without-changelog', + packageJsonProject() + ) + const changelogPath = join(cwd, 'CHANGELOG.md') + const changelog = await fs.readFile(changelogPath, 'utf8') + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + const result = await project.bump({ + as: 'patch', + skipChangelog: true + }) + + expect(result).toBe(true) + expect(project.changedFiles).toMatchObject([expect.stringMatching(/package\.json$/)]) + expect(project.versionUpdates[0].notes).toBe('') + expect(await fs.readFile(changelogPath, 'utf8')).toBe(changelog) + }) + it('should not add placeholder when generated release notes are not empty', async () => { const { cwd } = await packageJsonProject() const project = new PackageJsonProject({ diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts index 013da59..00c68d2 100644 --- a/packages/core/src/project/project.ts +++ b/packages/core/src/project/project.ts @@ -13,14 +13,19 @@ import { extractLastReleaseFromFile, preamblePartial } from '../change-log.js' -import { getReleaseType } from '../utils.js' +import { + getPrereleaseIdentifier, + getPrereleaseIdentifierBase, + getReleaseType +} from '../utils.js' import type { ProjectOptions, ProjectBumpOptions, ProjectVersionUpdate, ProjectTagsOptions, ProjectReleaseOptions, - ProjectPublishOptions + ProjectPublishOptions, + ProjectRevertOptions } from './project.types.js' export type * from './project.types.js' @@ -190,6 +195,7 @@ export abstract class Project { baseVersion, as, prerelease, + snapshot, firstRelease: firstReleaseOption, tagPrefix, preset = bumpDefaultOptions.preset @@ -237,10 +243,15 @@ export abstract class Project { return null } + const prereleaseIdentifier = getPrereleaseIdentifier( + prerelease, + snapshot + ) const nextVersion = semver.inc( version, - getReleaseType(releaseType, version, prerelease), - prerelease + getReleaseType(releaseType, version, prereleaseIdentifier), + prereleaseIdentifier, + getPrereleaseIdentifierBase(snapshot) ) return nextVersion @@ -267,6 +278,7 @@ export abstract class Project { tagPrefix, preset = bumpDefaultOptions.preset, dryRun, + skipChangelog, logger } = options const { projectPath } = manifest @@ -276,6 +288,7 @@ export abstract class Project { notes: '' } + this.versionUpdates.push(versionUpdate) this.changedFiles.push(...versionUpdate.files) const changelogPath = join(projectPath, changelogFile!) @@ -287,31 +300,67 @@ export abstract class Project { logger?.verbose(`${name}: ${version} -> ${nextVersion}`) } - const notes = new ConventionalChangelog(gitClient) - .loadPreset(preset, _ => import(_)) - .commits({ - path: projectPath - }) - .tags({ - prefix: tagPrefix - }) - .readRepository() - .context({ - version: nextVersion - }) - .writer({ - preamblePartial - }) - .write() + if (!skipChangelog) { + const notes = new ConventionalChangelog(gitClient) + .loadPreset(preset, _ => import(_)) + .commits({ + path: projectPath + }) + .tags({ + prefix: tagPrefix + }) + .readRepository() + .context({ + version: nextVersion + }) + .writer({ + preamblePartial + }) + .write() - versionUpdate.notes = dryRun - ? await concatStringStream(notes) - : await addReleaseNotes(changelogPath, notes) + versionUpdate.notes = dryRun + ? await concatStringStream(notes) + : await addReleaseNotes(changelogPath, notes) - logger?.verbose(`Release notes:\n\n${versionUpdate.notes}`) + logger?.verbose(`Release notes:\n\n${versionUpdate.notes}`) - this.changedFiles.push(changelogPath) - this.versionUpdates.push(versionUpdate) + this.changedFiles.push(changelogPath) + } + + return true + } + + /** + * Revert version updates. + * @param options - The options to use for reverting. + * @returns Whether version updates were reverted. + */ + async revert( + options: ProjectRevertOptions = {}, + filesSet: Set = new Set() + ) { + const { + manifest, + versionUpdates + } = this + const { + dryRun, + logger + } = options + + if (!versionUpdates.length) { + return false + } + + for (const update of versionUpdates) { + logger?.verbose(`Reverting ${update.name}: ${update.to} -> ${update.from}`) + update.files.forEach(file => filesSet.add(file)) + + await manifest.writeVersion(update.from, dryRun) + } + + this.changedFiles = this.changedFiles.filter(file => !filesSet.has(file)) + this.versionUpdates.length = 0 return true } diff --git a/packages/core/src/project/project.types.ts b/packages/core/src/project/project.types.ts index 35c4958..2d1b80e 100644 --- a/packages/core/src/project/project.types.ts +++ b/packages/core/src/project/project.types.ts @@ -52,6 +52,14 @@ export interface ProjectBumpOptions { * The pre-release identifier to use. */ prerelease?: string + /** + * The snapshot pre-release identifier to use with timestamp suffix. + */ + snapshot?: string + /** + * Skip changelog generation. + */ + skipChangelog?: boolean /** * Whether this is the first release. * By default will be auto detected based on tag existence. @@ -108,4 +116,17 @@ export interface ProjectPublishOptions { * Skip publishing. */ skip?: boolean + /** + * The NPM tag to use for publishing. + */ + tag?: string | ((version: string, prerelease: readonly (string | number)[] | null) => string) + /** + * Whether to perform git checks before publishing. + */ + gitChecks?: boolean +} + +export interface ProjectRevertOptions { + dryRun?: boolean + logger?: ChildLogger } diff --git a/packages/core/src/releaser.spec.ts b/packages/core/src/releaser.spec.ts index 0a38645..8abb465 100644 --- a/packages/core/src/releaser.spec.ts +++ b/packages/core/src/releaser.spec.ts @@ -6,7 +6,10 @@ import { expect } from 'vitest' import { firstFromStream } from '@simple-libs/stream-utils' -import { packageJsonProject } from 'test' +import { + forkProject, + packageJsonProject +} from 'test' import { PackageJsonProject } from './project/packageJson.js' import { Releaser } from './releaser.js' @@ -41,5 +44,33 @@ describe('core', () => { expect(tag).toBe('v2.1.0') expect(packageJson.version).toBe('2.1.0') }) + + it('should revert version updates', async () => { + const { cwd } = await forkProject('revert', packageJsonProject({ + name: 'revert-package-json-project' + })) + const project = new PackageJsonProject({ + path: join(cwd, 'package.json') + }) + const releaser = new Releaser({ + project, + silent: true + }) + .bump({ + as: 'patch', + skipChangelog: true + }) + .revert() + + await releaser.run() + + const packageJson = JSON.parse( + await fs.readFile(join(cwd, 'package.json'), 'utf-8') + ) + + expect(packageJson.version).toBe('2.0.0') + expect(project.changedFiles).toEqual([]) + expect(project.versionUpdates).toEqual([]) + }) }) }) diff --git a/packages/core/src/releaser.ts b/packages/core/src/releaser.ts index 5a3b01e..1c9765e 100644 --- a/packages/core/src/releaser.ts +++ b/packages/core/src/releaser.ts @@ -270,6 +270,34 @@ export class Releaser< }) } + /** + * Enqueue a task to revert version update files. + * @returns Project releaser instance for chaining. + */ + revert() { + return this.enqueue(async () => { + const { + project, + logger + } = this + const { dryRun } = this.options + + logger.info('revert', 'Reverting version updates...') + + const done = await project.revert({ + dryRun, + logger: logger.createChild('revert') + }) + + if (!done) { + logger.info('revert', 'No version updates to revert.') + return + } + + this.state.bump = false + }) + } + /** * Enqueue a task to tag the project with the new version. * @param options diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 065c173..b92a8ac 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -42,6 +42,27 @@ function getCurrentActiveType(version: string) { return undefined } +function getSnapshotTimestamp(date = new Date()) { + return date.toISOString().replace(/\D/g, '').slice(0, 14) +} + +export function getPrereleaseIdentifier( + prerelease?: string, + snapshot?: string +) { + if (snapshot) { + return `${snapshot}.${getSnapshotTimestamp()}` + } + + return prerelease +} + +export function getPrereleaseIdentifierBase(snapshot?: string) { + return snapshot + ? false + : undefined +} + export function getReleaseType( releaseType: ReleaseType, version: string, diff --git a/packages/github-action/src/options.ts b/packages/github-action/src/options.ts index 2d6edda..2543696 100644 --- a/packages/github-action/src/options.ts +++ b/packages/github-action/src/options.ts @@ -40,6 +40,7 @@ export function getInputOptions() { version: getInput('bump-version'), as: getInput('bump-as'), prerelease: getInput('bump-prerelease'), + snapshot: getInput('bump-snapshot'), firstRelease: getOptionalBooleanInput('bump-first-release'), skip: getOptionalBooleanInput('bump-skip'), byProject: getOptionalJsonInput('bump-by-project') diff --git a/packages/github-action/src/releaser.ts b/packages/github-action/src/releaser.ts index a736fa7..7ccec6b 100644 --- a/packages/github-action/src/releaser.ts +++ b/packages/github-action/src/releaser.ts @@ -1,5 +1,6 @@ import type { getOctokit } from '@actions/github' import { + type PickOverridableOptions, type Project, type ReleaserOptions, Releaser @@ -143,6 +144,28 @@ export class ReleaserGithubAction

extends Releaser< .run(check ? ifReleaseCommit : undefined) } + /** + * Run all steps to publish a snapshot version. + * @param snapshotTag - NPM tag and snapshot pre-release identifier. + */ + async runSnapshotAction(snapshotTag: string) { + if (!snapshotTag) { + throw new Error('Snapshot tag is required.') + } + + await this + .bump({ + snapshot: snapshotTag, + skipChangelog: true + } as PickOverridableOptions) + .publish({ + tag: snapshotTag, + gitChecks: false + } as PickOverridableOptions) + .revert() + .run() + } + /** * Run action based on the context. */ diff --git a/packages/npm/src/publish.ts b/packages/npm/src/publish.ts index b37a412..80cedfa 100644 --- a/packages/npm/src/publish.ts +++ b/packages/npm/src/publish.ts @@ -7,7 +7,6 @@ import { throwProcessError } from '@simple-libs/child-process-utils' export interface PublishOptions extends ProjectPublishOptions { access?: string - tag?: string | ((version: string, prerelease: readonly (string | number)[] | null) => string) otp?: string env?: Record workspaces?: boolean diff --git a/packages/pnpm/src/publish.ts b/packages/pnpm/src/publish.ts index a105191..34d9287 100644 --- a/packages/pnpm/src/publish.ts +++ b/packages/pnpm/src/publish.ts @@ -7,9 +7,7 @@ import { throwProcessError } from '@simple-libs/child-process-utils' export interface PublishOptions extends ProjectPublishOptions { access?: string - tag?: string | ((version: string, prerelease: readonly (string | number)[] | null) => string) otp?: string - gitChecks?: boolean env?: Record workspaces?: boolean }