diff --git a/package.json b/package.json index a0c3ff283..47699940c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lint-staged": "^15.0.0", "listr2": "^8.0.0", "minimist": "^1.2.8", + "simple-git": "^3.27.0", "semver": "^7.6.3", "upath": "^2.0.1" }, diff --git a/packages/ckeditor5-dev-release-tools/lib/tasks/commitandtag.js b/packages/ckeditor5-dev-release-tools/lib/tasks/commitandtag.js index dedde23a1..427f0ff5d 100644 --- a/packages/ckeditor5-dev-release-tools/lib/tasks/commitandtag.js +++ b/packages/ckeditor5-dev-release-tools/lib/tasks/commitandtag.js @@ -4,9 +4,8 @@ */ import upath from 'upath'; -import { tools } from '@ckeditor/ckeditor5-dev-utils'; import { glob } from 'glob'; -import shellEscape from 'shell-escape'; +import { simpleGit } from 'simple-git'; const { toUnix } = upath; @@ -27,22 +26,18 @@ export default async function commitAndTag( { version, files, cwd = process.cwd( return; } - const shExecOptions = { - cwd: normalizedCwd, - async: true, - verbosity: 'silent' - }; + const git = simpleGit( { + baseDir: normalizedCwd + } ); - // Run the command separately for each file to avoid exceeding the maximum command length on Windows, which is 32767 characters. - for ( const filePath of filePathsToAdd ) { - await tools.shExec( `git add ${ shellEscape( [ filePath ] ) }`, shExecOptions ); - } + const { all: availableTags } = await git.tags(); + const tagForVersion = availableTags.find( tag => tag.endsWith( version ) ); - const escapedVersion = { - commit: shellEscape( [ `Release: v${ version }.` ] ), - tag: shellEscape( [ `v${ version }` ] ) - }; + // Do not commit and create tags if a tag is already taken. It might happen when a release job is restarted. + if ( tagForVersion ) { + return; + } - await tools.shExec( `git commit --message ${ escapedVersion.commit } --no-verify`, shExecOptions ); - await tools.shExec( `git tag ${ escapedVersion.tag }`, shExecOptions ); + await git.commit( `Release: v${ version }.`, filePathsToAdd ); + await git.addTag( `v${ version }` ); } diff --git a/packages/ckeditor5-dev-release-tools/tests/tasks/commitandtag.js b/packages/ckeditor5-dev-release-tools/tests/tasks/commitandtag.js index 5b8dfd929..6f648a988 100644 --- a/packages/ckeditor5-dev-release-tools/tests/tasks/commitandtag.js +++ b/packages/ckeditor5-dev-release-tools/tests/tasks/commitandtag.js @@ -4,92 +4,107 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import shellEscape from 'shell-escape'; -import { tools } from '@ckeditor/ckeditor5-dev-utils'; import { glob } from 'glob'; import commitAndTag from '../../lib/tasks/commitandtag.js'; +import { simpleGit } from 'simple-git'; +vi.mock( 'simple-git' ); vi.mock( 'glob' ); -vi.mock( 'shell-escape' ); -vi.mock( '@ckeditor/ckeditor5-dev-utils' ); describe( 'commitAndTag()', () => { + let stubs; + beforeEach( () => { + stubs = { + git: { + tags: vi.fn(), + commit: vi.fn(), + addTag: vi.fn() + } + }; + + vi.spyOn( process, 'cwd' ).mockReturnValue( '/home/ckeditor' ); + + vi.mocked( simpleGit ).mockReturnValue( stubs.git ); + vi.mocked( glob ).mockResolvedValue( [] ); - vi.mocked( shellEscape ).mockImplementation( v => `'${ v[ 0 ] }'` ); + + stubs.git.tags.mockResolvedValue( { + all: [] + } ); } ); it( 'should not create a commit and tag if there are no files modified', async () => { - await commitAndTag( {} ); + await commitAndTag( { files: [] } ); - expect( vi.mocked( tools.shExec ) ).not.toHaveBeenCalled(); + expect( stubs.git.commit ).not.toHaveBeenCalled(); + expect( stubs.git.addTag ).not.toHaveBeenCalled(); + } ); + + it( 'should not create a commit and tag if the specified version is already tagged', async () => { + stubs.git.tags.mockResolvedValue( { + all: [ + 'v1.0.0' + ] + } ); + await commitAndTag( { files: [ 'package.json' ], version: '1.0.0' } ); + + expect( stubs.git.commit ).not.toHaveBeenCalled(); + expect( stubs.git.addTag ).not.toHaveBeenCalled(); } ); it( 'should allow to specify custom cwd', async () => { vi.mocked( glob ).mockResolvedValue( [ 'package.json' ] ); - await commitAndTag( { version: '1.0.0', cwd: 'my-cwd' } ); - - expect( vi.mocked( tools.shExec ).mock.calls[ 0 ][ 1 ].cwd ).to.deep.equal( 'my-cwd' ); - expect( vi.mocked( tools.shExec ).mock.calls[ 1 ][ 1 ].cwd ).to.deep.equal( 'my-cwd' ); - expect( vi.mocked( tools.shExec ).mock.calls[ 2 ][ 1 ].cwd ).to.deep.equal( 'my-cwd' ); - } ); + await commitAndTag( { version: '1.0.0', cwd: 'my-cwd', files: [] } ); - it( 'should add provided files to git one by one', async () => { - vi.mocked( glob ).mockResolvedValue( [ - 'package.json', - 'README.md', - 'packages/custom-package/package.json', - 'packages/custom-package/README.md' - ] ); - - await commitAndTag( { - version: '1.0.0', - files: [ 'package.json', 'README.md', 'packages/*/package.json', 'packages/*/README.md' ] + expect( vi.mocked( simpleGit ) ).toHaveBeenCalledExactlyOnceWith( { + baseDir: 'my-cwd' } ); - expect( vi.mocked( tools.shExec ) ).toHaveBeenCalledTimes( 6 ); - expect( vi.mocked( tools.shExec ).mock.calls[ 0 ][ 0 ] ).to.equal( 'git add \'package.json\'' ); - expect( vi.mocked( tools.shExec ).mock.calls[ 1 ][ 0 ] ).to.equal( 'git add \'README.md\'' ); - expect( vi.mocked( tools.shExec ).mock.calls[ 2 ][ 0 ] ).to.equal( 'git add \'packages/custom-package/package.json\'' ); - expect( vi.mocked( tools.shExec ).mock.calls[ 3 ][ 0 ] ).to.equal( 'git add \'packages/custom-package/README.md\'' ); + expect( vi.mocked( glob ) ).toHaveBeenCalledExactlyOnceWith( expect.anything(), expect.objectContaining( { + cwd: 'my-cwd' + } ) ); } ); - it( 'should set correct commit message', async () => { + it( 'should use the default cwd when not specified', async () => { vi.mocked( glob ).mockResolvedValue( [ 'package.json' ] ); - await commitAndTag( { version: '1.0.0', packagesDirectory: 'packages' } ); + await commitAndTag( { version: '1.0.0', files: [] } ); + + expect( vi.mocked( simpleGit ) ).toHaveBeenCalledExactlyOnceWith( { + baseDir: '/home/ckeditor' + } ); - expect( vi.mocked( tools.shExec ).mock.calls[ 1 ][ 0 ] ).to.equal( 'git commit --message \'Release: v1.0.0.\' --no-verify' ); + expect( vi.mocked( glob ) ).toHaveBeenCalledExactlyOnceWith( expect.anything(), expect.objectContaining( { + cwd: '/home/ckeditor' + } ) ); } ); - it( 'should set correct tag', async () => { - vi.mocked( glob ).mockResolvedValue( [ 'package.json' ] ); + it( 'should commit given files with a release message', async () => { + vi.mocked( glob ).mockResolvedValue( [ 'package.json', 'packages/ckeditor5-foo/package.json' ] ); - await commitAndTag( { version: '1.0.0', packagesDirectory: 'packages' } ); + await commitAndTag( { version: '1.0.0', packagesDirectory: 'packages', files: [ '**/package.json' ] } ); - expect( vi.mocked( tools.shExec ).mock.calls[ 2 ][ 0 ] ).to.equal( 'git tag \'v1.0.0\'' ); + expect( vi.mocked( glob ) ).toHaveBeenCalledExactlyOnceWith( expect.anything(), expect.objectContaining( { + absolute: true, + nodir: true + } ) ); + + expect( stubs.git.commit ).toHaveBeenCalledExactlyOnceWith( + 'Release: v1.0.0.', + [ + 'package.json', + 'packages/ckeditor5-foo/package.json' + ] + ); } ); - it( 'should escape arguments passed to a shell command', async () => { - vi.mocked( glob ).mockResolvedValue( [ - 'package.json', - 'README.md', - 'packages/custom-package/package.json', - 'packages/custom-package/README.md' - ] ); - - await commitAndTag( { - version: '1.0.0', - files: [ 'package.json', 'README.md', 'packages/*/package.json', 'packages/*/README.md' ] - } ); + it( 'should add a tag to the created commit', async () => { + vi.mocked( glob ).mockResolvedValue( [ 'package.json' ] ); + + await commitAndTag( { version: '1.0.0', packagesDirectory: 'packages' } ); - expect( vi.mocked( shellEscape ) ).toHaveBeenCalledTimes( 6 ); - expect( vi.mocked( shellEscape ).mock.calls[ 0 ][ 0 ] ).to.deep.equal( [ 'package.json' ] ); - expect( vi.mocked( shellEscape ).mock.calls[ 1 ][ 0 ] ).to.deep.equal( [ 'README.md' ] ); - expect( vi.mocked( shellEscape ).mock.calls[ 2 ][ 0 ] ).to.deep.equal( [ 'packages/custom-package/package.json' ] ); - expect( vi.mocked( shellEscape ).mock.calls[ 3 ][ 0 ] ).to.deep.equal( [ 'packages/custom-package/README.md' ] ); - expect( vi.mocked( shellEscape ).mock.calls[ 4 ][ 0 ] ).to.deep.equal( [ 'Release: v1.0.0.' ] ); - expect( vi.mocked( shellEscape ).mock.calls[ 5 ][ 0 ] ).to.deep.equal( [ 'v1.0.0' ] ); + expect( stubs.git.addTag ).toHaveBeenCalledExactlyOnceWith( 'v1.0.0' ); } ); } );