├── test.js ├── action.js ├── action.yml ├── README.md └── lib.js /test.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./lib.js').test() 3 | console.log("OK") 4 | -------------------------------------------------------------------------------- /action.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core') 2 | const lib = require('./lib.js') 3 | 4 | function main() { 5 | try { 6 | let env = {} 7 | lib.parseOpts.keys.forEach(function(key) { 8 | let value = core.getInput(key) 9 | if (value != '') { 10 | env[key] = value 11 | } 12 | }) 13 | let opts = lib.parseOpts(env) 14 | core.info("Parsed options: " + JSON.stringify(opts)) 15 | let nextVersion = lib.getNextVersion(opts) 16 | if (nextVersion != null) { 17 | let tag = lib.applyVersion(opts, nextVersion) 18 | core.setOutput('tag', tag) 19 | let renderedVersion = lib.renderVersion(nextVersion) 20 | core.setOutput('version', renderedVersion) 21 | if (opts.exportEnv !== null) { 22 | core.exportVariable(opts.exportEnv, renderedVersion) 23 | } 24 | } else { 25 | core.info("No version release triggered") 26 | } 27 | } catch(e) { 28 | console.log(e) 29 | core.setFailed(e.message) 30 | } 31 | } 32 | 33 | main() 34 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Autorelease tagger' 2 | description: 'Create version tags based on git tags and commits' 3 | inputs: 4 | numComponents: 5 | description: |- 6 | Number of version components (e.g. semver uses `3`) 7 | required: false 8 | releaseTrigger: 9 | description: |- 10 | `auto` (default): every commit is a new release. `commit`: only release when the commit message includes a release instruction 11 | required: false 12 | defaultBump: 13 | description: |- 14 | Default bump to apply when there's no specific instruction in commit messages (`major`|`minor`|`patch`). Default: `minor`. 15 | required: false 16 | maxBump: 17 | description: deprecated 18 | required: false 19 | minBump: 20 | description: |- 21 | Minimum bump to apply (e.g. setting to `minor` enforces this action won't allow a patch release) 22 | required: false 23 | versionTemplate: 24 | description: |- 25 | Template for fine-tuning the version generation. This is designed to be passed the base branch name. 26 | `refs/heads/` prefix is stripped, if present. Any string which doesn't look like a version template will be ignored. 27 | If a version template is given (e.g. `v1.2.x`), that will override `minBump` / `maxBump` / `numComponents`, and additionally 28 | ensure the created version starts with `v1.2.` 29 | doTag: 30 | description: |- 31 | Run `git tag` 32 | required: false 33 | doPush: 34 | description: |- 35 | Run `git push` on the created tag 36 | required: false 37 | exportEnv: 38 | description: |- 39 | Export the version to an environment variable with the given name. 40 | required: false 41 | 42 | outputs: 43 | tag: 44 | description: 'The new tag, only set if a tag is required' 45 | version: 46 | description: 'The new version, only set if a tag is required' 47 | 48 | runs: 49 | using: 'node12' 50 | main: 'action.js' 51 | 52 | branding: 53 | icon: 'tag' 54 | color: 'orange' 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Autorelease Tagger 2 | 3 | ### Generates new version tags from git history 4 | 5 | The process, in detail: 6 | 7 | - Use `git describe` to figure out the nearest ancestor version tag (`v*`) of the current commit 8 | - If that tag is pointing to this commit, do nothing 9 | - Otherwise, figure out the next version by looking at the _first line_ of all commits between the last version and the current commit: 10 | - figure out the bump strategy: 11 | - extract any instances of `[(major|minor|patch)(-release)?]` (i.e. `[major]`, `[minor]`, `[patch]`, `[major-release]`, etc) 12 | - use the highest one found 13 | - if no explicit bump is found, use` defaultBump` (defaults to `minor`) 14 | - if the bump strategy is greater than `minBump` (default unset), use `minBump` instead (conversely used to ensure all new `master` commits create minor versions instead of patch releases) 15 | - apply the bump to make a new version: 16 | - if `releaseTrigger` is `auto`, a new release is always made 17 | - if `releaseTrigger` is `commit`, a new release is only made if the commit message contains `[release]` (or `[major-release]`, `[minor-release]`, `[patch-release]`) 18 | - export the new version as an environment variable (if a key is provided in `exportEnv`) 19 | - tag it (if `doTag`, default `true`) 20 | - push it (if `doPush`, default `true` except for Pull Requests) 21 | 22 | ### versionTemplate: 23 | 24 | As of 2020-12, a `versionTemplate` option is allowed, which provides a convenient way to specify `numComponents`, `minBump`, as well as validating that the chosen version matches the expected pattern. 25 | 26 | Examples include: 27 | 28 | `v1.2.x`: allow only patch increments 29 | `v1.x.x`: allow minor or patch increments 30 | `v1.x.0`: allow only minor increments 31 | `vx.x.x`: equivalent to `numComponents: 3` 32 | 33 | The handling of `versionTemplate` is intentionally complex, to "do the right thing" for common use cases without requiring the use of unweildy workflow expressions: 34 | 35 | - any leading `refs/heads/` is stripped off 36 | - if the value does not begin with a version component (optional leading `v`, then at least one digit, then `.`), it will be ignored 37 | - if you supply both `versionTemplate` and `numComponents`, the max() of the two values is taken (so you can have a `v2.x` branch but still use 3 components) 38 | - if you supply both `versionTemplate` and `minBump`, `minBump` is ignored (typically `minBump` is used only on the main branch, to use on a version branch you will need to name it e.g. `v2.x.0`) 39 | 40 | This lets you pass in the branch name and have it work for both `master` and appropriately-named version branches, like so: 41 | 42 | ```yaml 43 | - uses: timbertson/autorelease-tagger-action@v1 44 | with: 45 | numComponents: 3 46 | minBump: minor 47 | versionTemplate: ${{ github.base_ref || github.ref }} 48 | ``` 49 | 50 | That will use the `base_ref` (destination branch) for a pull request, and the current branch for a push event. 51 | For the main branch it acts like a versionTemplate of `vx.x.0` thanks to the default `numComponents` and `minBump`. 52 | 53 | # Big thanks 54 | 55 | Inpired by the [Github Tag Bump](https://github.com/marketplace/actions/github-tag-bump) action, but with a few improvements: 56 | - use `git describe` 57 | - implement in JS (well, it's better than bash), with tests 58 | - minBump 59 | - not hardcoded to 3 release components 60 | - stricter bump detection (matches whole words, not just substring) 61 | - support for explicit version template 62 | - still does validation and tagging on PR branches (`doPush` defaults to false for PRs) 63 | -------------------------------------------------------------------------------- /lib.js: -------------------------------------------------------------------------------- 1 | let child_process = require('child_process') 2 | let fs = require('fs') 3 | 4 | function sh() { 5 | let args = Array.prototype.slice.call(arguments) 6 | console.log("+ " + args.join(' ')) 7 | let result = child_process.spawnSync(args[0], args.slice(1), { 8 | encoding: 'utf8', 9 | stdio: ['inherit', 'pipe', 'inherit'] 10 | }) 11 | // console.log(result) 12 | if (result.status != 0) { 13 | throw new Error("Command failed: " + args.join(' ')) 14 | } 15 | return result.stdout.trim() 16 | } 17 | 18 | let renderVersion = exports.renderVersion = function renderVersion(v) { 19 | return v.join('.') 20 | } 21 | 22 | function tagOfVersion(v) { 23 | return "v" + renderVersion(v) 24 | } 25 | 26 | function extendTo(length, array) { 27 | array = array.slice() 28 | while(array.length < length) { 29 | array.push(0) 30 | } 31 | return array 32 | } 33 | 34 | function initialVersion(opts) { 35 | return extendTo(opts.numComponents, opts.pinComponents) 36 | } 37 | 38 | const applyBump = (function() { 39 | function doApply(opts, current, bumpIdx) { 40 | current = extendTo(opts.numComponents, current) 41 | let version = current.slice(0, bumpIdx) 42 | version.push(current[bumpIdx]+1) 43 | let proposed = extendTo(opts.numComponents, version) 44 | 45 | // if pinComponents is set, ensure we bump up to the version required: 46 | for (let i=0; i part) { 52 | return doApply(opts, proposed, i) 53 | } 54 | } 55 | return proposed 56 | } 57 | 58 | return (function applyBump(opts, current, action) { 59 | let bumpIdx = action.bump 60 | 61 | if (opts.minBump != null && bumpIdx > opts.minBump) { 62 | // requested e.g. a patch bump, but minBump is minor. That's fine, just promote it 63 | bumpIdx = opts.minBump 64 | console.log("Note: forcing "+renderBumpIndex(bumpIdx)+" because of minBump") 65 | } 66 | if (bumpIdx < opts.maxBump) { 67 | throw new Error("Requested bump ("+renderBumpIndex(bumpIdx)+") is greater than maxBump ("+renderBumpIndex(opts.maxBump)+")") 68 | } 69 | 70 | if (bumpIdx >= opts.numComponents) { 71 | throw new Error("Tried to bump component " + renderBumpIndex(bumpIdx) + " but there are only "+ current.length + " components") 72 | } 73 | 74 | return doApply(opts, current, bumpIdx) 75 | }) 76 | })() 77 | 78 | function parsePart(p) { 79 | let digits = p.match(/^[0-9]+/) 80 | if (digits == null) { 81 | throw new Error("Invalid version component: " + p) 82 | } 83 | return parseInt(digits[0], 10) 84 | } 85 | 86 | function splitParts(v) { 87 | if (v[0] == 'v') { 88 | v = v.slice(1) 89 | } 90 | return v.split('.') 91 | } 92 | 93 | function parseVersion(v) { 94 | return splitParts(v).map(parsePart) 95 | } 96 | 97 | let parseVersionTemplate = function(v) { 98 | if (v.indexOf('refs/heads/') === 0) { 99 | v = v.slice(11) 100 | } 101 | if (v === '') { 102 | return null 103 | } 104 | let parts = splitParts(v) 105 | try { 106 | // this is unfortunately lax, but github's super weak expression language 107 | // warrants being lenient here 108 | if (parts[0] != 'x') { 109 | parsePart(parts[0]) 110 | } 111 | } catch { 112 | console.log("Ignoring versionTemplate ("+ v +") as it looks like a branch name") 113 | return null 114 | } 115 | let pinComponents = [] 116 | let numComponents = parts.length 117 | let maxBump = null 118 | let minBump = null 119 | let err = new Error("Invalid version template: " + v) 120 | parts.forEach(function(part, idx) { 121 | if (part == 'x') { 122 | if (minBump !== null) { 123 | // we already saw `x.0`, all the following parts should be `0` 124 | throw err 125 | } 126 | if (maxBump === null) { 127 | maxBump = idx 128 | } 129 | } else { 130 | if (maxBump !== null) { 131 | // we already saw an `x` component, all the following ones should be either `x` or `0` 132 | if (part == '0') { 133 | if (minBump == null) { 134 | minBump = idx-1 135 | } 136 | } else { 137 | throw err 138 | } 139 | } else { 140 | pinComponents.push(parsePart(part)) 141 | } 142 | } 143 | }) 144 | if (maxBump == null) { 145 | // we didn't see any `.x` components, e.g it's a plain `v1.2`. This is only useful if you explicitly set `numComponents` to 3, which ends up acting like `v1.2.x` 146 | maxBump = numComponents 147 | } 148 | return { numComponents, maxBump, minBump, pinComponents } 149 | } 150 | 151 | function parseGitDescribe(output) { 152 | parts = output.split('-') 153 | if (parts.length == 1) { 154 | // just a git commit 155 | return null 156 | } else if (parts.length > 2) { 157 | // output is e.g. v1.3.0-3-gf32721e 158 | let tag = parts.slice(0, parts.length - 2).join('-') 159 | return { 160 | tag: tag, 161 | version: parseVersion(tag) 162 | } 163 | } else { 164 | throw new Error("Unexpected `git describe` output: " + output) 165 | } 166 | } 167 | 168 | function commitLinesSince(tag) { 169 | // if we're running on a PR, use the head ref (branch to be merged) 170 | // instead of the HEAD (which is actually a merge of the PR against `master`) 171 | return sh('git', 'log', '--format=format:%s', tag + '..' + getPRImplementationBranch(), '--') 172 | } 173 | 174 | let bumpAliases = ["major", "minor", "patch"] 175 | function renderBumpIndex(i) { 176 | return bumpAliases[i] || "[index " + String(i) + "]" 177 | } 178 | 179 | function parseBumpAlias(alias) { 180 | switch (alias) { 181 | case "major": return 0 182 | case "minor": return 1 183 | case "patch": return 2 184 | default: throw new Error("Invalid bump alias: " + alias) 185 | } 186 | } 187 | 188 | function parseCommitLines(opts, commitLines) { 189 | let alwaysRelease = opts.releaseTrigger == 'always' 190 | function parse(label) { 191 | let withoutRelease = label.replace(/-release$/, "") 192 | if (bumpAliases.includes(withoutRelease)) { 193 | return { 194 | bump: parseBumpAlias(withoutRelease), 195 | release: withoutRelease != label 196 | } 197 | } else { 198 | return { 199 | bump: null, 200 | release: (label == 'release') 201 | } 202 | } 203 | } 204 | 205 | if (commitLines.length == 0) { 206 | return { release: false, bump: null } 207 | } 208 | let tags = commitLines.match(/\[\S+\]/gm) || [] 209 | // console.log("tags: " + JSON.stringify(tags)) 210 | let labels = (tags 211 | .map((tag) => tag.trim().replace(/\[|\]/g, '')) 212 | .map(parse) 213 | ) 214 | // console.log(JSON.stringify(commitLines) + ' => ' + JSON.stringify(labels)) 215 | 216 | let doRelease = Boolean(opts.releaseTrigger == 'always' || labels.find((desc) => desc.release)) 217 | let bumps = labels.map((d) => d.bump).filter((x) => x != null).sort((a,b) => a - b) 218 | let bump = opts.defaultBump 219 | if (bumps.length > 0) { 220 | bump = bumps[0] 221 | console.log("Applying explicit bump: " + renderBumpIndex(bump)) 222 | } 223 | return { 224 | release: doRelease, 225 | bump 226 | } 227 | } 228 | 229 | let parseOpts = exports.parseOpts = function(env) { 230 | function map(key, fn, dfl) { 231 | if (parseOpts.keys.indexOf(key) === -1) { 232 | throw new Error("key not defined in parseOpts.keys: " + key) 233 | } 234 | if (env.hasOwnProperty(key)) { 235 | return fn(env[key]) 236 | } else { 237 | return dfl; 238 | } 239 | } 240 | let identity = (x) => x 241 | function orElse(key, dfl) { 242 | return map(key, (x) => x, dfl) 243 | } 244 | function validate(key, dfl, fn) { 245 | let v = orElse(key, dfl) 246 | if (fn(v)) { 247 | return v 248 | } else { 249 | throw new Error("invalid "+key+": " + v) 250 | } 251 | } 252 | 253 | function preferVersionTemplate(key, { supplied, fromVersion }) { 254 | console.log("Using `"+key+"` derived from `versionTemplate` ("+fromVersion+") over explicit parameter ("+supplied+")") 255 | return fromVersion 256 | } 257 | 258 | function useMaxOfSettings(key, { supplied, fromVersion }) { 259 | let result = Math.max(supplied, fromVersion) 260 | console.log("Using maximum from `"+key+"` ("+supplied+") and `versionTemplate` ("+fromVersion+")") 261 | return result 262 | } 263 | 264 | let versionTemplateOpts = map('versionTemplate', parseVersionTemplate, null) 265 | // console.log('opts', env, versionTemplateOpts) 266 | 267 | function mergeFromVersionTemplate(key, fn, merge, dfl) { 268 | if (versionTemplateOpts === null) { 269 | return map(key, fn, dfl) 270 | } 271 | let supplied = map(key, fn, null) 272 | let fromVersion = versionTemplateOpts[key] 273 | if (supplied === null) { 274 | return fromVersion 275 | } 276 | if (supplied === fromVersion) { 277 | return supplied 278 | } else { 279 | return merge(key, { supplied, fromVersion }) 280 | } 281 | } 282 | 283 | let defaultDoPush = process.env['GITHUB_EVENT_NAME'] == 'pull_request' ? 'false' : 'true' 284 | 285 | let isBoolString = (x) => ["true","false"].includes(x) 286 | let opts = { 287 | releaseTrigger: validate("releaseTrigger", "always", (x) => ["always", "commit"].includes(x)), 288 | 289 | numComponents: mergeFromVersionTemplate('numComponents', (i) => parseInt(i), useMaxOfSettings, 3), 290 | minBump: mergeFromVersionTemplate('minBump', parseBumpAlias, preferVersionTemplate, null), 291 | maxBump: mergeFromVersionTemplate("maxBump", parseBumpAlias, preferVersionTemplate, 0), 292 | pinComponents: mergeFromVersionTemplate('pinComponents', identity, preferVersionTemplate, []), 293 | 294 | defaultBump: map('defaultBump', parseBumpAlias, null), 295 | doTag: validate("doTag", "true", isBoolString) === "true", 296 | doPush: validate("doPush", "true", isBoolString) === "true", 297 | exportEnv: map('exportEnv', identity, null), 298 | } 299 | 300 | // Aim for defaultBump (or 1), but cap to the range defined by minDefault / maxDefault 301 | // Due to the visual (left->right) indexes we want an index <= minDefaultBump 302 | // and >= maxDefaultBump 303 | let targetDefaultBump = opts.defaultBump == null ? 1 : opts.defaultBump; 304 | let minDefaultBump = opts.minBump == null ? opts.numComponents : opts.minBump 305 | let maxDefaultBump = opts.maxBump == null ? 0 : opts.maxBump 306 | opts.defaultBump = Math.min(Math.max(targetDefaultBump, maxDefaultBump), minDefaultBump) 307 | if (opts.defaultBump != null && opts.defaultBump != targetDefaultBump) { 308 | console.log("Using defaultBump: " 309 | + renderBumpIndex(opts.defaultBump) 310 | + " over configured value (" 311 | + renderBumpIndex(targetDefaultBump) 312 | + ") to fit minBump / maxBump") 313 | } 314 | 315 | return opts 316 | } 317 | parseOpts.keys = ['numComponents', 'releaseTrigger', 'defaultBump', 'maxBump', 'minBump', 'doTag', 'doPush', 'versionTemplate', 'pinComponents', 'exportEnv'] 318 | 319 | function getPRDestinationBranch() { 320 | let prBranch = process.env['GITHUB_BASE_REF'] 321 | return prBranch ? 'origin/'+prBranch : 'HEAD' 322 | } 323 | 324 | function getPRImplementationBranch() { 325 | let prBranch = process.env['GITHUB_HEAD_REF'] 326 | return prBranch ? 'origin/'+prBranch : 'HEAD' 327 | } 328 | 329 | let getNextVersion = exports.getNextVersion = function(opts) { 330 | let fetchCmd = ['git', 'fetch', '--tags'] 331 | if (fs.existsSync('.git/shallow')) { 332 | fetchCmd.push('--unshallow') 333 | } 334 | sh.apply(null, fetchCmd) 335 | 336 | // For a PR, we find the last tag reachable from the base ref (the branch we're merging into), 337 | // rather than HEAD. Github creates the HEAD merge commit when you create / push a PR, 338 | // so it won't always contain everything in the target branch: 339 | // 340 | // * master (v1.2.3) 341 | // | 342 | // | * HEAD (PR auto merge commit) 343 | // |/ | 344 | // | * PR: my cool feature 345 | // | / 346 | // * master^ (v1.2.2) 347 | // | 348 | // (...) 349 | // 350 | // If we just used HEAD, we'd pick a conflicting `v1.2.3` for this PR and fail, 351 | // even though once merged it would correctly pick v1.2.4 352 | // 353 | // In the case where you merge a version branch into master (i.e. both have version tags), 354 | // the PR will naturally only consider the master branch. Once merged, `--first-parent` 355 | // will ensure that `git describe` only searches the mainline history, not the version branch. 356 | 357 | let tagSearchRef = getPRDestinationBranch() 358 | 359 | let describeOutput = sh('git', 'describe', '--tags', '--first-parent', '--match', 'v*', '--always', '--long', tagSearchRef) 360 | console.log("Git describe output: "+ describeOutput) 361 | let current = parseGitDescribe(describeOutput) 362 | if (current == null) { 363 | console.log("No current version detected") 364 | return initialVersion(opts) 365 | } else { 366 | console.log("Current version: " + renderVersion(current.version) + " (from tag "+current.tag+")") 367 | } 368 | let action = parseCommitLines(opts, commitLinesSince(current.tag)) 369 | if (!action.release) { 370 | return null 371 | } 372 | return applyBump(opts, current.version, action) 373 | } 374 | 375 | let applyVersion = exports.applyVersion = function(opts, version) { 376 | let tag = tagOfVersion(version) 377 | console.log("Applying version "+ tag) 378 | if (opts.doTag) { 379 | sh('git', 'tag', tag, 'HEAD') 380 | if (opts.doPush) { 381 | sh('git', 'push', 'origin', 'tag', tag) 382 | } 383 | } 384 | return tag 385 | } 386 | 387 | exports.main = function() { 388 | let opts = parseOpts(process.env) 389 | let nextVersion = getNextVersion(opts) 390 | if (nextVersion != null) { 391 | applyVersion(opts, nextVersion) 392 | } else { 393 | console.log("No version release triggered") 394 | } 395 | } 396 | 397 | exports.test = function() { 398 | function assertEq(a,b, ctx) { 399 | if(b === undefined) { 400 | throw new Error("Expected value is undefined") 401 | } 402 | let aDesc = JSON.stringify(a) 403 | let bDesc = JSON.stringify(b) 404 | if(aDesc !== bDesc) { 405 | let desc = "Expected:\n " + bDesc + ", got\n "+ aDesc 406 | if (ctx) desc += " ("+ctx+")" 407 | throw new Error(desc) 408 | } 409 | } 410 | 411 | function assertThrows() { 412 | let args = Array.prototype.slice.call(arguments) 413 | let fn = args.shift() 414 | let msg = args.pop() 415 | let threw = false 416 | try { 417 | fn.apply(null, args) 418 | } catch(e) { 419 | threw = true 420 | assertEq(e.message, msg) 421 | } 422 | if (!threw) { 423 | throw new Error("Function didn't fail (expected: " + msg + ")") 424 | } 425 | } 426 | 427 | assertEq(parsePart("08"), 8) 428 | assertEq(parsePart("1-rc2"), 1) 429 | assertThrows(parsePart, "v1", "Invalid version component: v1") 430 | assertThrows(parsePart, "", "Invalid version component: ") 431 | 432 | assertEq(parseVersion("v1.2.3"), [1,2,3]) 433 | assertEq(parseVersion("v1"), [1]) 434 | assertEq(parseVersion("1"), [1]) 435 | assertThrows(parseVersion, "a", "Invalid version component: a") 436 | 437 | assertEq(parseGitDescribe("v1.2.3-1-gabcd"), { tag: "v1.2.3", version: [1,2,3]}) 438 | assertEq(parseGitDescribe("v1.2-rc1.3-1-gabcd"), { tag: "v1.2-rc1.3", version: [1,2,3] }) 439 | assertEq(parseGitDescribe("gabcd"), null) 440 | assertThrows(parseGitDescribe, "v1.2-gabcd", "Unexpected `git describe` output: v1.2-gabcd") 441 | 442 | assertEq(parseVersionTemplate(""), null) 443 | assertEq(parseVersionTemplate("v1.x"), { numComponents: 2, maxBump: 1, minBump: null, pinComponents: [1] }) 444 | assertEq(parseVersionTemplate("refs/heads/v1.x"), { numComponents: 2, maxBump: 1, minBump: null, pinComponents: [1] }) 445 | assertEq(parseVersionTemplate("refs/heads/1.x"), { numComponents: 2, maxBump: 1, minBump: null, pinComponents: [1] }) 446 | assertEq(parseVersionTemplate("refs/heads/1"), { numComponents: 1, maxBump: 1, minBump: null, pinComponents: [1] }) 447 | assertEq(parseVersionTemplate("refs/heads/master"), null) 448 | assertEq(parseVersionTemplate("v1.2.x"), { numComponents: 3, maxBump: 2, minBump: null, pinComponents: [1,2] }) 449 | assertEq(parseVersionTemplate("v3.x.x"), { numComponents: 3, maxBump: 1, minBump: null, pinComponents: [3] }) 450 | assertEq(parseVersionTemplate("v3.x.0"), { numComponents: 3, maxBump: 1, minBump: 1, pinComponents: [3] }) 451 | assertEq(parseVersionTemplate("vx.x.x"), { numComponents: 3, maxBump: 0, minBump: null, pinComponents: [] }) 452 | assertEq(parseVersionTemplate("vx.x.0"), { numComponents: 3, maxBump: 0, minBump: 1, pinComponents: [] }) 453 | assertEq(parseVersionTemplate("vx.0.0"), { numComponents: 3, maxBump: 0, minBump: 0, pinComponents: [] }) 454 | assertThrows(parseVersionTemplate, "v3.x.2", "Invalid version template: v3.x.2") 455 | assertThrows(parseVersionTemplate, "v3.x.2", "Invalid version template: v3.x.2") 456 | assertThrows(parseVersionTemplate, "vx.0.x", "Invalid version template: vx.0.x") 457 | assertThrows(parseVersionTemplate, "v1.a.x", "Invalid version component: a") 458 | 459 | let defaultOpts = parseOpts({}) 460 | let manualRelease = { releaseTrigger: 'commit', defaultBump: 1 } 461 | function assertParseCommitLines(lines, expected, opts) { 462 | if (!opts) { opts = defaultOpts } 463 | assertEq(parseCommitLines(opts, lines.join("\n")), expected, "parsing lines: " + JSON.stringify(lines)) 464 | } 465 | assertParseCommitLines([], { release: false, bump: null }) 466 | assertParseCommitLines(["[major] thing"], { release: true, bump: 0 }) 467 | assertParseCommitLines(["[minor]"], { release: true, bump: 1 }) 468 | assertParseCommitLines(["some [patch]"], { release: true, bump: 2 }) 469 | assertParseCommitLines(["[other]: thing"], { release: true, bump: 1 }) 470 | assertParseCommitLines(["[other]: thing"], { release: false, bump: 1 }, manualRelease) 471 | assertParseCommitLines(["[release]: thing"], { release: true, bump: 1 }, manualRelease) 472 | assertParseCommitLines(["[major-release]: thing"], { release: true, bump: 0 }, manualRelease) 473 | 474 | assertParseCommitLines(["[release]", "[minor]"], { release: true, bump: 1 }, manualRelease) 475 | assertParseCommitLines(["[minor]", "[major]:"], { release: true, bump: 0 }) 476 | assertParseCommitLines(["[minor]", "[patch]"], { release: true, bump: 1 }) 477 | assertParseCommitLines(['[ma','jor]'], { release: true, bump: 1 }) 478 | 479 | assertEq(initialVersion({...defaultOpts, numComponents: 2}), [0,0]) 480 | assertEq(initialVersion({...defaultOpts, pinComponents: [2,3,0], numComponents: 4}), [2,3,0,0]) 481 | 482 | assertEq(applyBump(defaultOpts, [1,2,3], { bump: 0 }), [2,0,0]) 483 | assertEq(applyBump(defaultOpts, [1,2,3], { bump: 1 }), [1,3,0]) 484 | assertEq(applyBump(defaultOpts, [1,2,3], { bump: 2 }), [1,2,4]) 485 | assertEq(applyBump({ ...defaultOpts, minBump: 0 }, [1,2,3], { bump: 2 }), [2,0,0]) 486 | assertThrows(applyBump, defaultOpts, [1,2,3], { bump: 3 }, "Tried to bump component [index 3] but there are only 3 components") 487 | assertEq(applyBump({...defaultOpts, numComponents: 4 }, [1,2], { bump: 3 }), [1,2,0,1]) 488 | assertThrows(applyBump, {...defaultOpts, maxBump: 1}, [1,2,3], { bump: 0 }, "Requested bump (major) is greater than maxBump (minor)") 489 | 490 | // NOTE: we apply validation before applying pinComponents. 491 | // If you specify maxBump=minor and versionTemplate=2.x on 492 | // a commit above v1.3, we'll take that as a request to start a 2.x branch 493 | assertEq(applyBump({...defaultOpts, pinComponents: [2, 8]}, [1,2,3], { bump: 2 }), [2,8,0]) 494 | assertEq(applyBump({...defaultOpts, pinComponents: [1, 2]}, [1,2,3], { bump: 2 }), [1,2,4]) 495 | assertEq(applyBump({...defaultOpts, pinComponents: [1, 3]}, [1,2,3], { bump: 2 }), [1,3,0]) 496 | assertEq(applyBump({...defaultOpts, pinComponents: [1, 3], numComponents: 4}, [0,0,0], { bump: 3 }), [1, 3, 0, 0]) 497 | assertThrows(applyBump, {...defaultOpts, pinComponents: [1,1], versionTemplate: "v1.1.x" }, [1,2,3], { bump: 2 }, "New version (v1.2.4) is incompatible with versionTemplate (v1.1.x)") 498 | assertThrows(applyBump, {...defaultOpts, pinComponents: [1], versionTemplate: "v1.x.x" }, [1,2,3], { bump: 0 }, "New version (v2.0.0) is incompatible with versionTemplate (v1.x.x)") 499 | 500 | assertEq(parseOpts({}), { 501 | releaseTrigger:"always", 502 | numComponents:3, 503 | minBump:null, 504 | maxBump:0, 505 | pinComponents: [], 506 | defaultBump:1, 507 | doTag:true, 508 | doPush:true, 509 | exportEnv: null, 510 | }) 511 | 512 | assertEq(parseOpts({ 513 | releaseTrigger: 'commit', 514 | defaultBump: 'major', 515 | maxBump: 'major', 516 | minBump: 'patch', 517 | doTag: 'true', 518 | doPush: 'false', 519 | exportEnv: null, 520 | }), { 521 | releaseTrigger: "commit", 522 | numComponents: 3, 523 | minBump: 2, 524 | maxBump: 0, 525 | pinComponents: [], 526 | defaultBump: 0, 527 | doTag: true, 528 | doPush: false, 529 | exportEnv: null, 530 | }) 531 | 532 | assertEq(parseOpts({ minBump: 'patch' }).defaultBump, 1) 533 | assertEq(parseOpts({ minBump: 'major' }).defaultBump, 0) 534 | assertEq(parseOpts({ maxBump: 'patch' }).defaultBump, 2) 535 | 536 | assertEq(parseOpts({ maxBump: 'patch', defaultBump: 'major' }).defaultBump, 2) 537 | assertEq(parseOpts({ minBump: 'minor', defaultBump: 'patch' }).defaultBump, 1) 538 | assertEq(parseOpts({ maxBump: 'major', minBump: 'minor', defaultBump: 'patch' }).defaultBump, 1) 539 | assertEq(parseOpts({ maxBump: 'minor', minBump: 'patch', defaultBump: 'major' }).defaultBump, 1) 540 | 541 | assertEq(parseOpts({versionTemplate: 'v2.x'}).numComponents, 2) 542 | assertEq(parseOpts({versionTemplate: 'vx.0'}).minBump, 0) 543 | assertEq(parseOpts({versionTemplate: 'v1.x'}).maxBump, 1) 544 | assertEq(parseOpts({versionTemplate: 'v1.x'}).pinComponents, [1]) 545 | assertEq(parseOpts({versionTemplate: 'v1.2.x', defaultBump: 'major'}).defaultBump, 2) 546 | 547 | // test precedence of passing both versionTemplate and explicit settings: 548 | assertEq(parseOpts({versionTemplate: 'v2.x', numComponents: 3}).numComponents, 3, 'max') 549 | assertEq(parseOpts({versionTemplate: 'v2.x.x', numComponents: 2}).numComponents, 3, 'max') 550 | assertEq(parseOpts({versionTemplate: 'v2.x.x', minBump: 'minor'}).minBump, null, 'prefer versionTemplate') 551 | assertEq(parseOpts({versionTemplate: 'vx.x.x', maxBump: 'minor'}).maxBump, 0, 'prefer versionTemplate') 552 | 553 | assertEq(sh("echo", "1", "2"), "1 2") 554 | assertThrows(sh, "cat", "/ does_not_exist", "Command failed: cat / does_not_exist") 555 | 556 | function getAndApply(opts) { 557 | let version = getNextVersion(opts) 558 | if (version == null) { 559 | throw new Error("New version was not generated") 560 | } 561 | applyVersion(opts, version) 562 | return version 563 | } 564 | 565 | function dumpState() { 566 | console.log(sh('git', 'log', '--color', '--graph', 567 | '--pretty=format:%C(yellow)%h%Creset -%C(bold blue)%d%Creset %s %Cgreen(%cr) %C(bold)<%an>%Creset', 568 | '--abbrev-commit', '--date=relative', '--date-order', 'HEAD')) 569 | } 570 | 571 | // "integration test" of sorts, running with some real git repositories 572 | let os = require('os') 573 | let tempdir = fs.mkdtempSync(os.tmpdir() + '/autorelease-tagger-') 574 | try { 575 | console.log('tempdir: ' + tempdir) 576 | let origin = tempdir + '/origin' 577 | fs.mkdirSync(origin) 578 | process.chdir(origin) 579 | sh('git', 'init') 580 | function commit(msg) { 581 | sh('git', 'commit', '--allow-empty', '-m', msg) 582 | } 583 | commit('initial') 584 | let opts = {...defaultOpts, doPush: false} 585 | 586 | assertEq(getAndApply(opts), [0,0,0]) 587 | 588 | commit('work') 589 | assertEq(getAndApply(opts), [0,1,0]) 590 | 591 | commit('[major] work') 592 | assertEq(getAndApply(opts), [1,0,0]) 593 | // ensure v1.0.0 is further away in the history than the v0.1.1 594 | // we're going to make on a version branch 595 | commit('more work') 596 | commit('more work') 597 | commit('more work') 598 | 599 | 600 | // now test a version branch 601 | let versionBranch = 'v0.1.x' 602 | sh('git', 'checkout', '-b', versionBranch, 'v0.1.0') 603 | let versionOpts = parseOpts({versionTemplate: versionBranch, doPush: 'false' }) 604 | 605 | commit('v0.1.1') 606 | assertEq(getAndApply(versionOpts), [0,1,1]) 607 | 608 | // emulate a merged PR from a version branch 609 | sh('git', 'checkout', 'master') 610 | sh('git', 'merge', '--no-edit', versionBranch) 611 | let mergeCommit = sh('git', 'rev-parse', 'HEAD') 612 | 613 | assertEq(getAndApply(opts), [1,1,0], 'next version should be based on first parent') 614 | 615 | // emulate a PR against master from the version branch 616 | sh('git', 'checkout', versionBranch) 617 | commit('version work') 618 | 619 | let clone = tempdir + '/clone' 620 | sh('git', 'clone', origin, clone) 621 | process.chdir(clone) 622 | 623 | // now emulate a PR against master from the version branch (i.e. pretend HEAD is an auto merge commit for a PR) 624 | sh('git', 'checkout', '-b', 'pr-merge') 625 | sh('git', 'merge', '--no-edit', 'origin/'+versionBranch) 626 | process.env['GITHUB_BASE_REF'] = 'master' // has v1.1.0, even though it's not in HEAD 627 | process.env['GITHUB_HEAD_REF'] = versionBranch // has v0.1.0 but we should ignore this 628 | // dumpState() 629 | assertEq(getAndApply(opts), [1,2,0], 'next version should come from PR target') 630 | 631 | } finally { 632 | sh('rm', '-rf', tempdir) 633 | } 634 | } 635 | --------------------------------------------------------------------------------