From 9323fba2bae2ce702be39b47d4c46d1767316fd7 Mon Sep 17 00:00:00 2001 From: dangreen Date: Mon, 6 Jul 2026 16:19:17 +0400 Subject: [PATCH] feat(core,node-gha): resolve last release tag through project to support bundled release commits --- packages/core/package.json | 4 +- packages/core/src/project/monorepo.ts | 4 +- packages/core/src/project/project.ts | 49 +++++++---- packages/node-gha/src/project.spec.ts | 114 ++++++++++++++++++++++++++ packages/node-gha/src/project.ts | 39 +++++++++ packages/test/package.json | 2 +- pnpm-lock.yaml | 56 +++++++------ pnpm-workspace.yaml | 2 +- 8 files changed, 225 insertions(+), 45 deletions(-) create mode 100644 packages/node-gha/src/project.spec.ts diff --git a/packages/core/package.json b/packages/core/package.json index 0fca98c..13e1667 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,7 +59,7 @@ "test": "run -p lint test:unit test:types" }, "dependencies": { - "@conventional-changelog/git-client": "^3.0.1", + "@conventional-changelog/git-client": "^3.1.0", "@conventional-changelog/template": "^1.2.0", "@simple-libs/child-process-utils": "^2.0.0", "@simple-libs/hosted-git-info": "^2.0.0", @@ -67,6 +67,8 @@ "conventional-changelog": "^8.0.0", "conventional-changelog-conventionalcommits": "^10.2.0", "conventional-changelog-preset-loader": "^6.0.0", + "conventional-commits-filter": "^6.0.1", + "conventional-commits-parser": "^7.0.1", "conventional-recommended-bump": "^12.0.0", "semver": "^7.5.2" }, diff --git a/packages/core/src/project/monorepo.ts b/packages/core/src/project/monorepo.ts index 15d93c4..c218013 100644 --- a/packages/core/src/project/monorepo.ts +++ b/packages/core/src/project/monorepo.ts @@ -307,8 +307,8 @@ export abstract class MonorepoProject extends Project { }[] = [] const baseVersion = await this.manifest.getVersion() const tagPrefix = await this.getTagPrefix('') - const firstRelease = !await this.gitClient.getLastSemverTag({ - prefix: tagPrefix + const firstRelease = !await this.getLastReleaseTag({ + tagPrefix }) let hasBump = false let fixedVersion: string | undefined diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts index e8a2f5d..8dc3e65 100644 --- a/packages/core/src/project/project.ts +++ b/packages/core/src/project/project.ts @@ -176,17 +176,31 @@ 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. + * Get the last release tag for the project. + * @param options - The options to use for getting the tag. + * @returns The last release tag, `null` if not found. */ - async getMaintenanceBranches(options: ProjectReleaseOptions = {}): Promise { + async getLastReleaseTag(options: ProjectReleaseOptions = {}): Promise { + const { tagPrefix } = options const { gitClient, manifest } = this + + return await gitClient.getLastSemverTag({ + path: manifest.projectPath, + prefix: tagPrefix + }) + } + + /** + * 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 { manifest } = this const { tagPrefix = 'v' } = options - const { projectPath } = manifest const version = await manifest.getVersion() const currentVersion = semver.valid(version) @@ -195,9 +209,8 @@ export abstract class Project { } const [release] = await this.getReleaseData(options) - const previousTag = release?.previousTag || await gitClient.getLastSemverTag({ - path: projectPath, - prefix: tagPrefix + const previousTag = release?.previousTag || await this.getLastReleaseTag({ + tagPrefix }) if (previousTag) { @@ -252,11 +265,12 @@ export abstract class Project { return forcedVersion } + const lastReleaseTag = await this.getLastReleaseTag({ + tagPrefix + }) + if (typeof firstRelease === 'undefined') { - firstRelease = !await gitClient.getLastSemverTag({ - path: projectPath, - prefix: tagPrefix - }) + firstRelease = !lastReleaseTag } const version = baseVersion || await manifest.getVersion() @@ -275,7 +289,7 @@ export abstract class Project { .commits({ path: projectPath }) - .tag({ + .tag(lastReleaseTag || { prefix: tagPrefix }) .bump() @@ -347,17 +361,22 @@ export abstract class Project { } if (!skipChangelog) { + const lastReleaseTag = await this.getLastReleaseTag({ + tagPrefix + }) || undefined const notes = new ConventionalChangelog(gitClient) .loadPreset(preset, _ => import(_)) .commits({ - path: projectPath + path: projectPath, + from: lastReleaseTag }) .tags({ prefix: tagPrefix }) .readRepository() .context({ - version: nextVersion + version: nextVersion, + previousTag: lastReleaseTag }) .writer({ preamblePartial diff --git a/packages/node-gha/src/project.spec.ts b/packages/node-gha/src/project.spec.ts new file mode 100644 index 0000000..a085dcb --- /dev/null +++ b/packages/node-gha/src/project.spec.ts @@ -0,0 +1,114 @@ +import { join } from 'path' +import fs from 'fs/promises' +import { + describe, + expect, + it +} from 'vitest' +import { + createDirectory, + dummyCommit, + forkProject, + packageJsonProject +} from 'test' +import { NodeGhaProject } from './project.js' + +describe('node-gha', () => { + describe('project', () => { + describe('getLastReleaseTag', () => { + it('should find last release tag despite bundled release commits', async () => { + const { cwd, run } = await forkProject('node-gha-last-release-tag', packageJsonProject({ + name: 'node-gha-last-release-tag-project', + version: '2.0.0', + files: [ + 'dist' + ] + })) + const remote = await createDirectory('node-gha-last-release-tag-remote') + const project = new NodeGhaProject({ + path: join(cwd, 'package.json') + }) + + await run([ + ({ git }) => git.exec('-C', remote, 'init', '--bare'), + ({ git }) => git.exec('remote', 'set-url', 'origin', remote), + ({ cwd }) => fs.writeFile( + join(cwd, 'package.json'), + JSON.stringify({ + name: 'node-gha-last-release-tag-project', + version: '3.0.0', + files: [ + 'dist' + ] + }) + ), + ({ git }) => git.add('package.json'), + ({ git }) => git.commit({ + message: 'chore(release): 3.0.0' + }), + ({ cwd }) => fs.mkdir(join(cwd, 'dist'), { + recursive: true + }), + ({ cwd }) => fs.writeFile(join(cwd, 'dist/index.js'), 'built\n') + ]) + + await project.publish() + + await run([ctx => dummyCommit(ctx, 'fix')]) + + expect(await project.getLastReleaseTag()).toBe('v3.0.0') + + await project.bump() + + const [versionUpdate] = project.versionUpdates + + expect(versionUpdate.to).toBe('3.0.1') + expect(versionUpdate.notes).toContain('commit message for fix') + expect(versionUpdate.notes).not.toContain('commit message for feat') + }) + + it('should find last release tag for a maintenance branch', async () => { + const { cwd, run } = await forkProject('node-gha-maintenance-tag', packageJsonProject({ + name: 'node-gha-maintenance-tag-project', + version: '2.0.0', + files: [ + 'dist' + ] + })) + const remote = await createDirectory('node-gha-maintenance-tag-remote') + const project = new NodeGhaProject({ + path: join(cwd, 'package.json') + }) + + await run([ + ({ git }) => git.exec('-C', remote, 'init', '--bare'), + ({ git }) => git.exec('remote', 'set-url', 'origin', remote), + ({ cwd }) => fs.writeFile( + join(cwd, 'package.json'), + JSON.stringify({ + name: 'node-gha-maintenance-tag-project', + version: '3.0.0', + files: [ + 'dist' + ] + }) + ), + ({ git }) => git.add('package.json'), + ({ git }) => git.commit({ + message: 'chore(release): 3.0.0' + }), + ({ cwd }) => fs.mkdir(join(cwd, 'dist'), { + recursive: true + }), + ({ cwd }) => fs.writeFile(join(cwd, 'dist/index.js'), 'built\n') + ]) + + await project.publish() + + await run([({ git }) => git.exec('checkout', '-b', 'v2-maintenance', 'v2.0.0')]) + + expect(await project.getLastReleaseTag()).toBe('v2.0.0') + }) + }) + }) +}) diff --git a/packages/node-gha/src/project.ts b/packages/node-gha/src/project.ts index 22a00ba..d184581 100644 --- a/packages/node-gha/src/project.ts +++ b/packages/node-gha/src/project.ts @@ -1,6 +1,7 @@ import { type PackageJsonProjectOptions, type ProjectBumpOptions, + type ProjectReleaseOptions, PackageJsonProject } from '@simple-release/core' import { @@ -18,6 +19,44 @@ export type NodeGhaProjectPublishOptions = PublishOptions * A Node.js GitHub Actions project that publishes version refs to git branches and tags. */ export class NodeGhaProject extends PackageJsonProject { + private async isReachableFromHead(ref: string) { + try { + await this.gitClient.exec('merge-base', '--is-ancestor', ref, 'HEAD') + return true + } catch { + return false + } + } + + /** + * Get the last release tag for the project. + * GitHub Action release tags point to bundled commits in the release branches + * and are not reachable from the source branch, so all refs are walked + * and tags are matched to the current branch by their parent commits. + * @param options - The options to use for getting the tag. + * @returns The last release tag, `null` if not found. + */ + override async getLastReleaseTag(options: ProjectReleaseOptions = {}) { + const { tagPrefix = 'v' } = options + const tags = this.gitClient.getSemverTags({ + prefix: tagPrefix, + all: true + }) + + for await (const tag of tags) { + // A bundled release commit is amended from the release commit in the source branch, + // so the tag itself or its parent should be reachable from the current branch. + if ( + await this.isReachableFromHead(tag) + || await this.isReachableFromHead(`${tag}^`) + ) { + return tag + } + } + + return null + } + override async publish(options: NodeGhaProjectPublishOptions = {}): Promise { if (options.skip) { options.logger?.info('Skipping publish') diff --git a/packages/test/package.json b/packages/test/package.json index 030fcf4..d465ea7 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -31,7 +31,7 @@ "test": "run -p lint test:types" }, "dependencies": { - "@conventional-changelog/git-client": "^3.0.1", + "@conventional-changelog/git-client": "^3.1.0", "@simple-libs/stream-utils": "^2.0.0", "@types/node": "^22.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 162fdf7..ebc2f29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,8 +97,8 @@ importers: packages/core: dependencies: '@conventional-changelog/git-client': - specifier: ^3.0.1 - version: 3.0.1(conventional-commits-filter@6.0.0)(conventional-commits-parser@7.0.0) + specifier: ^3.1.0 + version: 3.1.0(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.0.1) '@conventional-changelog/template': specifier: ^1.2.0 version: 1.2.0 @@ -113,13 +113,19 @@ importers: version: 2.0.0 conventional-changelog: specifier: ^8.0.0 - version: 8.0.0(conventional-commits-filter@6.0.0) + version: 8.0.0(conventional-commits-filter@6.0.1) conventional-changelog-conventionalcommits: specifier: ^10.2.0 version: 10.2.0 conventional-changelog-preset-loader: specifier: ^6.0.0 version: 6.0.0 + conventional-commits-filter: + specifier: ^6.0.1 + version: 6.0.1 + conventional-commits-parser: + specifier: ^7.0.1 + version: 7.0.1 conventional-recommended-bump: specifier: ^12.0.0 version: 12.0.0 @@ -266,8 +272,8 @@ importers: packages/test: dependencies: '@conventional-changelog/git-client': - specifier: ^3.0.1 - version: 3.0.1(conventional-commits-filter@6.0.0)(conventional-commits-parser@7.0.0) + specifier: ^3.1.0 + version: 3.1.0(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.0.1) '@simple-libs/stream-utils': specifier: ^2.0.0 version: 2.0.0 @@ -412,12 +418,12 @@ packages: conventional-commits-parser: optional: true - '@conventional-changelog/git-client@3.0.1': - resolution: {integrity: sha512-5gW6MaxbrErdhK2YopOY6EXU7iyuE0132wK4h/6a6n1iqML9G063hU3ixOAMb7CEGDkcqVrgrRRbK16QtMpdEg==} + '@conventional-changelog/git-client@3.1.0': + resolution: {integrity: sha512-Tqa/gHco2WJWa740NRjOrfKVvzIqxkZpecb8bemaQ8sKM5PXb1UK4uTyTb/1wIqNuOVaDOFxyBdhTIQZn6gdjQ==} engines: {node: '>=22'} peerDependencies: - conventional-commits-filter: ^6.0.0 - conventional-commits-parser: ^7.0.0 + conventional-commits-filter: ^6.0.1 + conventional-commits-parser: ^7.0.1 peerDependenciesMeta: conventional-commits-filter: optional: true @@ -1195,8 +1201,8 @@ packages: conventional-commit-types@3.0.0: resolution: {integrity: sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==} - conventional-commits-filter@6.0.0: - resolution: {integrity: sha512-taSykxCRrEryH1oTkqUa6bvIgSPWG7BRs4mwuwQR68099xRQCTBIIbu9vs6xHO2YW4iKuB4GIfAZVQW5BLYG6Q==} + conventional-commits-filter@6.0.1: + resolution: {integrity: sha512-cs+LadpH7Kpw0M3k8wurk+sOVVDAENA0iK4OBOrkL94j5lEVYRJ4j3zd2bhY9qgzyrPqthdcYT3axzRN7AliMg==} engines: {node: '>=22'} conventional-commits-parser@6.4.0: @@ -1204,8 +1210,8 @@ packages: engines: {node: '>=18'} hasBin: true - conventional-commits-parser@7.0.0: - resolution: {integrity: sha512-dZe/p+FgGQLNJFqaCgNdl8j6a7gI8xuaN30Wy5g7nvyK3jqOpNUEUZ3pGJ5D68h89uRh038FtkeOk/bnGmYkmg==} + conventional-commits-parser@7.0.1: + resolution: {integrity: sha512-6VtskFpPsNkGVk/TY2RnV/MEdKfvCPBtQZN9x8jh9+k5RWBQ+tiaWn5UFCzTr0Dd88iKx7xghxbjBRp5uIzp3g==} engines: {node: '>=22'} hasBin: true @@ -2538,14 +2544,14 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 - '@conventional-changelog/git-client@3.0.1(conventional-commits-filter@6.0.0)(conventional-commits-parser@7.0.0)': + '@conventional-changelog/git-client@3.1.0(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.0.1)': dependencies: '@simple-libs/child-process-utils': 2.0.0 '@simple-libs/stream-utils': 2.0.0 semver: 7.8.5 optionalDependencies: - conventional-commits-filter: 6.0.0 - conventional-commits-parser: 7.0.0 + conventional-commits-filter: 6.0.1 + conventional-commits-parser: 7.0.1 '@conventional-changelog/template@1.2.0': {} @@ -3193,18 +3199,18 @@ snapshots: dependencies: '@conventional-changelog/template': 1.2.0 '@simple-libs/stream-utils': 2.0.0 - conventional-commits-filter: 6.0.0 + conventional-commits-filter: 6.0.1 meow: 14.1.0 semver: 7.8.5 - conventional-changelog@8.0.0(conventional-commits-filter@6.0.0): + conventional-changelog@8.0.0(conventional-commits-filter@6.0.1): dependencies: - '@conventional-changelog/git-client': 3.0.1(conventional-commits-filter@6.0.0)(conventional-commits-parser@7.0.0) + '@conventional-changelog/git-client': 3.1.0(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.0.1) '@simple-libs/hosted-git-info': 2.0.0 '@simple-libs/normalize-package-data': 1.0.0 conventional-changelog-preset-loader: 6.0.0 conventional-changelog-writer: 9.1.0 - conventional-commits-parser: 7.0.0 + conventional-commits-parser: 7.0.1 fd-package-json: 2.0.0 meow: 14.1.0 transitivePeerDependencies: @@ -3212,24 +3218,24 @@ snapshots: conventional-commit-types@3.0.0: {} - conventional-commits-filter@6.0.0: {} + conventional-commits-filter@6.0.1: {} conventional-commits-parser@6.4.0: dependencies: '@simple-libs/stream-utils': 1.2.0 meow: 13.2.0 - conventional-commits-parser@7.0.0: + conventional-commits-parser@7.0.1: dependencies: '@simple-libs/stream-utils': 2.0.0 meow: 14.1.0 conventional-recommended-bump@12.0.0: dependencies: - '@conventional-changelog/git-client': 3.0.1(conventional-commits-filter@6.0.0)(conventional-commits-parser@7.0.0) + '@conventional-changelog/git-client': 3.1.0(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.0.1) conventional-changelog-preset-loader: 6.0.0 - conventional-commits-filter: 6.0.0 - conventional-commits-parser: 7.0.0 + conventional-commits-filter: 6.0.1 + conventional-commits-parser: 7.0.1 meow: 14.1.0 convert-source-map@2.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e5aed6d..4f87b6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,7 @@ allowBuilds: esbuild: true simple-git-hooks: true minimumReleaseAgeExclude: - - '@conventional-changelog/git-client@3.0.1' + - '@conventional-changelog/git-client@3.0.1 || 3.1.0' - '@conventional-changelog/template@1.2.0' - conventional-changelog-conventionalcommits@10.2.0 - conventional-changelog-preset-loader@6.0.0