diff --git a/.eslintrc.json b/.eslintrc.json index d097bf7..a3c4db0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,17 +1,18 @@ { - "env": { - "commonjs": true, - "es6": true, - "node": true - }, - "extends": "eslint:recommended", - "globals": { - "Atomics": "readonly", - "SharedArrayBuffer": "readonly" - }, - "parserOptions": { - "ecmaVersion": 2018 - }, - "rules": { - } -} \ No newline at end of file + "plugins": ["jest"], + "env": { + "commonjs": true, + "es6": true, + "node": true, + "jest/globals": true + }, + "extends": "eslint:recommended", + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2018 + }, + "rules": {} +} diff --git a/action.yml b/action.yml index b8c58be..a31f374 100644 --- a/action.yml +++ b/action.yml @@ -4,14 +4,14 @@ branding: icon: "layers" color: "blue" inputs: - main_branch: - description: "main branch" + branch: + description: "The branch name" required: true default: "master" - release_branch: - description: "the release branch pattern which be be used as a prefix for release branches" + tag_prefix: + description: "The prefix to use to identify tags" required: true - default: "release" + default: "v" major_pattern: description: "a string which, if present in a git commit, indicates that a change represents a major (breaking) change" required: true diff --git a/index.js b/index.js index 5223994..f85a9c6 100644 --- a/index.js +++ b/index.js @@ -10,77 +10,111 @@ const cmd = async (command, ...args) => { options.listeners = { stdout: (data) => { output += data.toString(); } }; + await exec.exec(command, args, options) - .catch(err => core.error(err)); + .catch(err => { core.error(`${command} ${args.join(' ')} failed: ${err}`); throw err; }); return output; }; async function run() { try { - const remotePrefix = 'origin/'; + const remote = await cmd('git', 'remote'); + const remoteExists = remote !== ''; + const remotePrefix = remoteExists ? 'origin/' : ''; - const releasePattern = `${remotePrefix}${core.getInput('release_branch', { required: true })}/*`; + const tagPrefix = core.getInput('tag_prefix', { required: true }); + const branch = `${remotePrefix}${core.getInput('branch', { required: true })}`; const majorPattern = core.getInput('major_pattern', { required: true }); const minorPattern = core.getInput('minor_pattern', { required: true }); - const mainBranch = `${remotePrefix}${core.getInput('main_branch', { required: true })}`; - let major = 0, minor = 0, patch = 0; + const releasePattern = `refs/tags/${tagPrefix}*`; + let major = 0, minor = 0, patch = 0, increment = 0; - let branches = await cmd( + let tag = (await cmd( 'git', - `branch`, - `-r`, - `--list`, + `for-each-ref`, `--format='%(refname:short)'`, `--sort=-committerdate`, releasePattern - ); + )).split(eol)[0].trim().replace(/'/g, ""); - var root; - if (branches === '') { - // no release branches yet, use the initial commit as the root - root = await cmd('git', `rev-list`, `--max-parents=0`, mainBranch); + let root; + if (tag === '') { + const isEmpty = (await cmd('git', `status`)).includes('No commits yet'); + if (isEmpty) { + // empty repo + core.info('Version is 0.0.0+0'); + core.setOutput("version", '0.0.0+0'); + core.setOutput("major", '0'); + core.setOutput("minor", '0'); + core.setOutput("patch", '0'); + core.setOutput("increment", '0'); + return; + } else { + // no release tags yet, use the initial commit as the root + root = ''; + } } else { - // find the merge base between the last - var releaseBranch = branches.split(eol)[0]; - var versionValues = releaseBranch.split('/')[1].split('.'); + // parse the version tag + let tagParts = tag.split('/'); + let versionValues = tagParts[tagParts.length - 1] + .substr(tagPrefix.length) + .split('.'); + major = parseInt(versionValues[0]); minor = parseInt(versionValues[1]); patch = parseInt(versionValues[2]); - root = await cmd('git', `merge-base`, releaseBranch, mainBranch); + if (isNaN(major) || isNaN(minor) || isNaN(patch)) { + throw `Invalid tag ${tag}`; + } + + root = await cmd('git', `merge-base`, tag, branch); } root = root.trim(); - let history = (await cmd('git', 'log', '--pretty="%s"', root, mainBranch)).split(eol); + const log = await cmd( + 'git', + 'log', + '--pretty="%s"', + '--author-date-order', + root === '' ? branch : `${root}..${branch}`); - patch++; - var increment = history.length - 1; - for (var i = 0; i < history.length; i++) { - if (history[i].indexOf(majorPattern) !== -1) { - major++; - minor = 0; - patch = 0; - increment = i; - break; - } else if (history[i].indexOf(minorPattern) !== -1) { - minor++; - patch = 0; - increment = i; - break; - } + let history = log + .trim() + .split(eol) + .reverse(); + + // Discover the change time from the history log by finding the oldest log + // that could set the version. + + const majorIndex = history.findIndex(x => x.includes(majorPattern)); + const minorIndex = history.findIndex(x => x.includes(minorPattern)); + + if (majorIndex !== -1) { + increment = history.length - (majorIndex + 1); + patch = 0; + minor = 0; + major++; + } else if (minorIndex !== -1) { + increment = history.length - (minorIndex + 1); + patch = 0; + minor++; + } else { + increment = history.length - 1; + patch++; } let version = `${major}.${minor}.${patch}`; core.info(`Version is ${version}+${increment}`); core.setOutput("version", version); - core.setOutput("major", major); - core.setOutput("minor", minor); - core.setOutput("patch", patch); - core.setOutput("increment", increment); + core.setOutput("major", major.toString()); + core.setOutput("minor", minor.toString()); + core.setOutput("patch", patch.toString()); + core.setOutput("increment", increment.toString()); } catch (error) { - console.log(error); + core.error(error); core.setFailed(error.message); } } diff --git a/index.test.js b/index.test.js index c59cc52..2b48a00 100644 --- a/index.test.js +++ b/index.test.js @@ -1,22 +1,232 @@ -/*global test */ const cp = require('child_process'); const path = require('path'); const process = require('process'); -// shows how the runner will run a javascript action with env / stdout protocol -test('test runs', () => { - process.env['INPUT_MAIN_BRANCH'] = "master"; - process.env['INPUT_RELEASE_BRANCH'] = "release"; - process.env['INPUT_MAJOR_PATTERN'] = "(MAJOR)"; - process.env['INPUT_MINOR_PATTERN'] = "(MINOR)"; +// Action input variables +process.env['INPUT_BRANCH'] = "master"; +process.env['INPUT_TAG_PREFIX'] = "v"; +process.env['INPUT_MAJOR_PATTERN'] = "(MAJOR)"; +process.env['INPUT_MINOR_PATTERN'] = "(MINOR)"; - const ip = path.join(__dirname, 'index.js'); +// Creates a randomly named git repository and returns a function to execute commands in it +const createTestRepo = () => { + const repoDirectory = `/tmp/test${Math.random().toString(36).substring(2, 15)}`; + cp.execSync(`mkdir ${repoDirectory} && git init ${repoDirectory}`); + // Configure up git user + cp.execSync(`git config user.name "Test User"`) + cp.execSync(`git config user.email "test@example.com"`); + + const run = (command) => execute(repoDirectory, command); + let i = 1; + + return { + clean: () => execute('/tmp', `rm -rf ${repoDirectory}`), + makeCommit: (msg) => { + run(`touch test${i++}`); + run(`git add --all`); + run(`git commit -m '${msg}'`); + }, + runAction: () => run(`node ${path.join(__dirname, 'index.js')}`), + exec: run + }; +}; + +// Executes a set of commands in the specified directory +const execute = (workingDirectory, command) => { try { - console.log(cp.execSync(`node ${ip}`, { env: process.env }).toString()); + return String(cp.execSync(command, { env: process.env, cwd: workingDirectory })); } catch (e) { console.error(String(e.stdout)); console.error(String(e.stderr)); throw e; } +}; + +test('Empty repository version is correct', () => { + const repo = createTestRepo(); // 0.0.0+0 + var result = repo.runAction(); + + expect(result).toMatch('Version is 0.0.0+0'); + + repo.clean(); }) + +test('Repository with commits shows increment', () => { + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit(`Second Commit`); // 0.0.1+1 + const result = repo.runAction(); + + expect(result).toMatch('Version is 0.0.1+1'); + + repo.clean(); +}) + +test('Minor update bumps minor version and resets increment', () => { + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit('Second Commit (MINOR)'); // 0.1.0+0 + const result = repo.runAction(); + + expect(result).toMatch('Version is 0.1.0+0'); + + repo.clean(); +}); + +test('Major update bumps major version and resets increment', () => { + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit('Second Commit (MAJOR)'); // 1.0.0+0 + const result = repo.runAction(); + + + expect(result).toMatch('Version is 1.0.0+0'); + + repo.clean(); +}); + +test('Multiple major commits are idempotent', () => { + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit('Second Commit (MAJOR)'); // 1.0.0+0 + repo.makeCommit('Third Commit (MAJOR)'); // 1.0.0+1 + const result = repo.runAction(); + + + expect(result).toMatch('Version is 1.0.0+1'); + + repo.clean(); +}); + + +test('Minor commits after a major commit are ignored', () => { + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit('Second Commit (MAJOR)'); // 1.0.0+0 + repo.makeCommit('Third Commit (MINOR)'); // 1.0.0+1 + const result = repo.runAction(); + + expect(result).toMatch('Version is 1.0.0+1'); + + repo.clean(); +}); + +test('Release branches start new version', () => { + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit('Second Commit'); // 0.0.1+1 + repo.exec('git tag v0.0.1'); + repo.makeCommit('Third Commit'); // 0.0.2+0 + const result = repo.runAction(); + + + expect(result).toMatch('Version is 0.0.2+0'); + + repo.clean(); +}); + +test('Version pulled from last release branch', () => { + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.exec('git tag v0.0.1'); + repo.makeCommit('Second Commit'); // 0.0.2+0 + repo.exec('git tag v5.6.7'); + repo.makeCommit('Third Commit'); // 5.6.7+0 + const result = repo.runAction(); + + + expect(result).toMatch('Version is 5.6.8+0'); + + repo.clean(); +}); + + +test('Tags on branches are used', () => { + + // This test checks that tags are counted correctly even if they are not on + // the main branch: + // master o--o--o--o <- expecting v0.0.2 + // \ + // release o--o <- taged v0.0.1 + + + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit('Second Commit'); // 0.0.1+1 + repo.makeCommit('Third Commit'); // 0.1.1+2 + repo.exec('git checkout -b release/0.0.1') + repo.makeCommit('Fourth Commit'); // 0.1.1+3 + repo.exec('git tag v0.0.1'); + repo.exec('git checkout master'); + repo.makeCommit('Fifth Commit'); // 0.0.2.0 + const result = repo.runAction(); + + expect(result).toMatch('Version is 0.0.2+0'); + + repo.clean(); +}); + +test('Merged tags do not affect version', () => { + + // This test checks that merges are counted correctly + // master o--o--o--o---o <- expecting v0.0.2+1 + // \ / + // release o--o <- taged v0.0.1 + + + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit('Second Commit'); // 0.0.1+1 + repo.makeCommit('Third Commit'); // 0.1.1+2 + repo.exec('git checkout -b release/0.0.1') + repo.makeCommit('Fourth Commit'); // 0.1.1+3 + repo.exec('git tag v0.0.1'); + repo.exec('git checkout master'); + repo.makeCommit('Fifth Commit'); // 0.0.2+1 + repo.exec('git merge release/0.0.1'); + const result = repo.runAction(); + + expect(result).toMatch('Version is 0.0.2+1'); + + repo.clean(); +}); + +test('Merged tags do not affect version', () => { + + // This test checks that merges don't override tags + + // Tagged v0.0.2 + // v + // master o--o--o---o---o <- expecting v0.0.3+1 + // \ / + // release o---o <- taged v0.0.1 + + + const repo = createTestRepo(); // 0.0.0+0 + + repo.makeCommit('Initial Commit'); // 0.0.1+0 + repo.makeCommit('Second Commit'); // 0.0.1+1 + repo.makeCommit('Third Commit'); // 0.1.1+2 + repo.exec('git checkout -b release/0.0.1') + repo.makeCommit('Fourth Commit'); // 0.1.1+3 + repo.exec('git tag v0.0.1'); + repo.exec('git checkout master'); + repo.makeCommit('Fifth Commit'); // 0.0.2.0 + repo.exec('git tag v0.0.2'); + repo.exec('git merge release/0.0.1'); + const result = repo.runAction(); + + expect(result).toMatch('Version is 0.0.3+1'); + + repo.clean(); +}); diff --git a/package-lock.json b/package-lock.json index 2c87112..b5985d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -458,6 +458,12 @@ "@types/istanbul-lib-report": "*" } }, + "@types/json-schema": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.3.tgz", + "integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==", + "dev": true + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -479,6 +485,32 @@ "integrity": "sha512-gCubfBUZ6KxzoibJ+SCUc/57Ms1jz5NjHe4+dI2krNmU5zCPAphyLJYyTOg06ueIyfj+SaCUqmzun7ImlxDcKg==", "dev": true }, + "@typescript-eslint/experimental-utils": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.10.0.tgz", + "integrity": "sha512-FZhWq6hWWZBP76aZ7bkrfzTMP31CCefVIImrwP3giPLcoXocmLTmr92NLZxuIcTL4GTEOE33jQMWy9PwelL+yQ==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/typescript-estree": "2.10.0", + "eslint-scope": "^5.0.0" + } + }, + "@typescript-eslint/typescript-estree": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.10.0.tgz", + "integrity": "sha512-oOYnplddQNm/LGVkqbkAwx4TIBuuZ36cAQq9v3nFIU9FmhemHuVzAesMSXNQDdAzCa5bFgCrfD3JWhYVKlRN2g==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "eslint-visitor-keys": "^1.1.0", + "glob": "^7.1.6", + "is-glob": "^4.0.1", + "lodash.unescape": "4.0.1", + "semver": "^6.3.0", + "tsutils": "^3.17.1" + } + }, "@zeit/ncc": { "version": "0.20.5", "resolved": "https://registry.npmjs.org/@zeit/ncc/-/ncc-0.20.5.tgz", @@ -1400,6 +1432,15 @@ "v8-compile-cache": "^2.0.3" } }, + "eslint-plugin-jest": { + "version": "23.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-23.1.1.tgz", + "integrity": "sha512-2oPxHKNh4j1zmJ6GaCBuGcb8FVZU7YjFUOJzGOPnl9ic7VA/MGAskArLJiRIlnFUmi1EUxY+UiATAy8dv8s5JA==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "^2.5.0" + } + }, "eslint-scope": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz", @@ -3546,6 +3587,12 @@ "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", "dev": true }, + "lodash.unescape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz", + "integrity": "sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw=", + "dev": true + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5089,6 +5136,15 @@ "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==", "dev": true }, + "tsutils": { + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz", + "integrity": "sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index c86aedd..41d83a2 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@zeit/ncc": "^0.20.5", "eslint": "^6.3.0", + "eslint-plugin-jest": "^23.1.1", "jest": "^24.9.0" } } diff --git a/readme.md b/readme.md index 97af035..be46951 100644 --- a/readme.md +++ b/readme.md @@ -7,17 +7,25 @@ This action produces a [semantic version](https://semver.org) for a repository using the repository's git history. +This action is designed to facilitate assigning version numbers during a build +automatically while publishing version that only increment by one value per +release. To accomplish this, the next version number is calculated along with +a commit increment indicating the number of commits for this version. The +commit messages are inspected to determine the type of version change the next +version represents. Including the term `(MAJOR)` or `(MINOR)` in the commit +message alters the type of change the next version will represent. + # Usage ```yaml -- uses: paulhatch/semantic-version@v1 +- uses: paulhatch/semantic-version@v1-beta with: - # The main branch to count commits on - main_branch: "master" - # The release branch pattern which be be used as a prefix for release branches - release_branch: "release" + # The branch to count commits on + branch: "master" + # The prefix to use to identify tags + tag_prefix: "v" # A string which, if present in a git commit, indicates that a change represents a major (breaking) change major_pattern: "(MAJOR)" # Same as above except indicating a minor change