├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── bump.nim ├── bump.nim.cfg ├── bump.nimble └── tests ├── .gitignore ├── rando ├── blue.nimble └── red.nimble └── tbump.nim /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | insert_final_newline = true 4 | indent_size = 2 5 | trim_trailing_whitespace = true 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | schedule: 4 | - cron: '30 5 * * *' 5 | 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | branches: 11 | - '*' 12 | 13 | jobs: 14 | changes: 15 | # Disable the filter on scheduled runs because we don't want to skip those 16 | if: github.event_name != 'schedule' 17 | continue-on-error: true # Makes sure errors won't stop us 18 | runs-on: ubuntu-latest 19 | outputs: 20 | src: ${{ steps.filter.outputs.src }} 21 | steps: 22 | # For PRs the path filter check with Github API, so no need to checkout 23 | # for them. 24 | - if: github.event_name != 'pull_request' 25 | name: Checkout (if not PR) 26 | uses: actions/checkout@v3 27 | 28 | - uses: dorny/paths-filter@v2 29 | id: filter 30 | with: 31 | filters: | 32 | src: 33 | - '**.cfg' 34 | - '**.nims' 35 | - '**.nim' 36 | - '**.nimble' 37 | - 'tests/**' 38 | - '.github/workflows/ci.yml' 39 | 40 | build: 41 | # Build if the files we care about are changed. 42 | needs: changes 43 | # Make sure to always run regardless of whether the filter success or not. 44 | # When the filter fails there won't be an output, so checking for `false` 45 | # state is better than checking for `true`. 46 | # 47 | # The always() function here is required for the job to always run despite 48 | # what Github docs said, see: https://github.com/actions/runner/issues/491 49 | if: always() && !cancelled() && needs.changes.outputs.src != 'false' 50 | 51 | strategy: 52 | fail-fast: false 53 | matrix: 54 | os: [ubuntu-latest] 55 | nim: [devel, version-2-0, version-1-6] 56 | name: '${{ matrix.os }} (${{ matrix.nim }})' 57 | runs-on: ${{ matrix.os }} 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v3 61 | with: 62 | path: project 63 | 64 | - name: Setup Nim 65 | uses: alaviss/setup-nim@0.1.1 66 | with: 67 | path: nim 68 | version: ${{ matrix.nim }} 69 | 70 | - name: Dependencies 71 | shell: bash 72 | run: | 73 | cd project 74 | git fetch --unshallow 75 | nimble --accept build 76 | 77 | - name: Old Balls 78 | if: matrix.nim == 'version-1-6' 79 | shell: bash 80 | run: | 81 | cd project 82 | nimble --accept install "https://github.com/disruptek/balls@3.9.11" 83 | 84 | - name: Balls 85 | if: matrix.nim != 'version-1-6' 86 | shell: bash 87 | run: | 88 | cd project 89 | nimble --accept install "https://github.com/disruptek/balls" 90 | 91 | - name: Tests 92 | shell: bash 93 | run: | 94 | cd project 95 | balls --path="." 96 | 97 | - name: Build docs 98 | if: ${{ matrix.docs }} == 'true' 99 | shell: bash 100 | run: | 101 | cd project 102 | branch=${{ github.ref }} 103 | branch=${branch##*/} 104 | nimble doc --project --outdir:docs \ 105 | '--git.url:https://github.com/${{ github.repository }}' \ 106 | '--git.commit:${{ github.sha }}' \ 107 | "--git.devel:$branch" \ 108 | bump.nim 109 | # Ignore failures for older Nim 110 | cp docs/{the,}index.html || true 111 | 112 | - name: Publish docs 113 | if: > 114 | github.event_name == 'push' && github.ref == 'refs/heads/master' && 115 | matrix.target == 'ubuntu-latest' && matrix.nim == 'version-1-6' 116 | uses: crazy-max/ghaction-github-pages@v3 117 | with: 118 | build_dir: project/docs 119 | env: 120 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 121 | 122 | # Set check-required on this 123 | success: 124 | needs: build 125 | if: always() 126 | runs-on: ubuntu-latest 127 | name: 'All check passes' 128 | steps: 129 | - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') 130 | name: 'Fail when previous jobs fails' 131 | run: | 132 | echo "::error::One of the previous jobs failed" 133 | exit 1 134 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | deps 2 | nimblemeta.json 3 | bin/ 4 | nim.cfg 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andy Davidoff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bump 2 | 3 | [![Test Matrix](https://github.com/disruptek/bump/workflows/CI/badge.svg)](https://github.com/disruptek/bump/actions?query=workflow%3ACI) 4 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/disruptek/bump?style=flat)](https://github.com/disruptek/bump/releases/latest) 5 | ![Minimum supported Nim version](https://img.shields.io/badge/nim-1.6.11%2B-informational?style=flat&logo=nim) 6 | [![License](https://img.shields.io/github/license/disruptek/bump?style=flat)](#license) 7 | 8 | It just **bumps** the value of the `version` in your `.nimble` file, commits it, tags it, and pushes it. 9 | 10 | `hub` from https://github.com/github/hub enables GitHub-specific functionality. 11 | 12 | For an explanation of the "social contract" that is semantic versioning, see https://semver.org/ 13 | 14 | **Note:** I only test bump on Linux against `git v2.23.0`, but it seems to work against `git v2.17.0`. It has also been tested on OS X with `git v2.23.0`. Platform-specific code in the tool: 15 | 16 | 1. we identify your current working directory differently on `macos` and `genode`, and perhaps some day, other platforms as well. 17 | 18 | 1. by the same token (well, not the **same** token, but...) if Nim ever invents a new `ExtSep` for your platform (ie. the character that separates filename from its extension), you can rebuild bump to use that new separator. 19 | 20 | If you had to read that section carefully, please file a bug report with your vendor. 21 | 22 | ## Usage 23 | 24 | By default, bump increments the patch number. 25 | ``` 26 | $ bump 27 | 🎉1.0.1 28 | 🍻bumped 29 | ``` 30 | 31 | You can set the Nim logging level to monitor progress or check assumptions. 32 | If built with `-d:debug`, you'll get `lvlDebug` output by default. Release 33 | builds default to `lvlNotice`, and the default log-level is set to `lvlInfo` 34 | otherwise. 35 | 36 | ``` 37 | $ bump --log lvlInfo 38 | ✔️git tag --list 39 | 🎉1.0.2 40 | ✔️git commit -m 1.0.2 /some/demo.nimble 41 | ✔️git tag -a -m 1.0.2 1.0.2 42 | ✔️git push 43 | ✔️git push --tags 44 | 🍻bumped 45 | ``` 46 | 47 | Please add a few words to describe the reason for the new version. These will 48 | show up in tags as well. 49 | ``` 50 | $ bump fixed a bug 51 | 🎉1.0.3: fixed a bug 52 | 🍻bumped 53 | ``` 54 | 55 | Major bumps are for changes that might disrupt another user of the software. 56 | ``` 57 | $ bump --major api redesign 58 | 🎉2.0.0: api redesign 59 | 🍻bumped 60 | ``` 61 | 62 | You should add minors when you add functionality. 63 | ``` 64 | $ bump --minor added a new feature 65 | 🎉2.1.0: added a new feature 66 | 🍻bumped 67 | ``` 68 | 69 | A dry-run option merely shows you the future version/message. 70 | ``` 71 | $ bump --dry-run what if i fix another bug? 72 | 🎉2.1.1: what if i fix another bug? 73 | $ bump fixed another bug! 74 | 🎉2.1.1: fixed another bug! 75 | 🍻bumped 76 | ``` 77 | 78 | You can specify the next version manually if necessary. 79 | ``` 80 | $ bump --manual 3.3.1 wrapper tracks version from upstream lib 81 | 🎉3.3.1: wrapper tracks version from upstream lib 82 | 🍻bumped 83 | ``` 84 | 85 | If you already use a `v` prefix for your tags, bump will add one, too. 86 | ``` 87 | $ bump strange tag ahead 88 | 🎉v2.1.2: strange tag ahead 89 | 🍻bumped 90 | ``` 91 | 92 | You can use `--v` to force the `v` prefix. This is might be necessary if you 93 | want a `v` prefix and you haven't created any tags yet, or if you have other 94 | atypical tags in `git tag --list`. 95 | ``` 96 | $ bump --v my first tag is a weird one 97 | 🎉v1.0.1: my first tag is a weird one 98 | 🍻bumped 99 | ``` 100 | 101 | If your last version had a `[vV]\.?` prefix, your next one will, too. 102 | ``` 103 | $ git tag -m 'a very bad idea' -a V.1.0.2 104 | $ bump going from bad to worse 105 | 🎉V.1.0.3: going from bad to worse 106 | 🍻bumped 107 | ``` 108 | 109 | You can commit the entire repository at once to reduce gratuitous commits. 110 | ``` 111 | $ bump --commit quick fix for simple buglet 112 | 🎉2.1.4: quick fix for simple buglet 113 | 🍻bumped 114 | ``` 115 | 116 | If you have `hub` installed, you can also mate a GitHub release to the new tag. 117 | ``` 118 | $ bump --minor --release add release option 119 | 🎉2.2.0: add release option 120 | 🍻bumped 121 | ``` 122 | 123 | Optionally specify a particular `.nimble` file to work on. 124 | ``` 125 | $ bump --nimble other.nimble 126 | 🎉2.6.10 127 | 🍻bumped 128 | ``` 129 | 130 | ## Complete Options via `--help` 131 | ``` 132 | Usage: 133 | bump [optional-params] [message: string...] 134 | increment the version of a nimble package, tag it, and push it via git 135 | Options(opt-arg sep :|=|spc): 136 | -h, --help print this cligen-erated help 137 | --help-syntax advanced: prepend,plurals,.. 138 | -m, --minor bool false increment the minor version field 139 | --major bool false increment the major version field 140 | -p, --patch bool true increment the patch version field 141 | -r, --release bool false also use `hub` to issue a GitHub release 142 | -d, --dry-run bool false just report the projected version 143 | -f=, --folder= string "" specify the location of the nimble file 144 | -n=, --nimble= string "" specify the nimble file to modify 145 | -l=, --log-level= Level lvlInfo specify Nim logging level 146 | -c, --commit bool false also commit any other unstaged changes 147 | -v, --v bool false prefix the version tag with an ugly `v` 148 | --manual= string "" manually set the new version to #.#.# 149 | ``` 150 | 151 | ## Library Use 152 | There are some procedures exported for your benefit; see [the documentation for the module as generated directly from the source](https://disruptek.github.io/bump/bump.html). 153 | 154 | ## License 155 | MIT 156 | -------------------------------------------------------------------------------- /bump.nim: -------------------------------------------------------------------------------- 1 | import std/os 2 | import std/options 3 | import std/osproc 4 | import std/strutils 5 | import std/strformat 6 | import std/nre 7 | 8 | from std/macros import nil 9 | 10 | import cutelog 11 | 12 | type 13 | Version* = tuple 14 | major: uint 15 | minor: uint 16 | patch: uint 17 | 18 | Target* = tuple 19 | repo: string 20 | package: string 21 | ext: string 22 | 23 | SearchResult* = tuple 24 | message: string 25 | found: Option[Target] 26 | 27 | const 28 | dotNimble = "".addFileExt("nimble") 29 | defaultExts = @["nimble"] 30 | logLevel = 31 | when defined(debug): 32 | lvlDebug 33 | elif defined(release): 34 | lvlNotice 35 | elif defined(danger): 36 | lvlNotice 37 | else: 38 | lvlInfo 39 | 40 | template crash(why: string) = 41 | ## a good way to exit bump() 42 | error why 43 | return 1 44 | 45 | proc `$`*(target: Target): string = 46 | result = target.repo / target.package & target.ext 47 | 48 | proc `$`*(ver: Version): string = 49 | result = &"{ver.major}.{ver.minor}.{ver.patch}" 50 | 51 | proc relativeParentPath*(dir: string): string = 52 | ## the parent directory as expressed relative to the directory supplied 53 | result = dir / ParDir 54 | 55 | proc isFilesystemRoot*(dir: string): bool = 56 | ## true if there are no higher directories in the fs tree 57 | result = sameFile(dir, dir.relativeParentPath) 58 | 59 | proc isNamedLikeDotNimble(dir: string; file: string): bool = 60 | ## true if it the .nimble filename (minus ext) matches the directory 61 | if dir == "" or file == "": 62 | return 63 | if not file.endsWith(dotNimble): 64 | return 65 | result = dir.lastPathPart == file.changeFileExt("") 66 | 67 | proc safeCurrentDir(): string = 68 | when nimvm: 69 | result = os.getEnv("PWD", os.getEnv("CD", "")) 70 | else: 71 | result = getCurrentDir() 72 | 73 | proc newTarget*(path: string): Target = 74 | let splat = path.splitFile 75 | result = (repo: splat.dir, package: splat.name, ext: splat.ext) 76 | 77 | proc findTargetWith(dir: string; cwd: proc (): string; target = ""; 78 | ascend = true; extensions = defaultExts): SearchResult = 79 | ## locate one, and only one, nimble file to work upon; dir is where 80 | ## to start looking, target is a .nimble or package name 81 | 82 | # viable selections are limited to the target and possible extensions 83 | var 84 | viable: seq[string] 85 | exts: seq[string] 86 | 87 | # an empty extension is acceptable in the extensions argument 88 | for extension in extensions.items: 89 | # create mypackage.nimble, mypackage.nimble-link 90 | viable.add target.addFileExt(extension) 91 | 92 | # create .nimble, .nimble-link 93 | exts.add "".addFileExt(extension) 94 | 95 | # search the directory for a .nimble file 96 | for component, filename in walkDir(dir): 97 | if component notin {pcFile, pcLinkToFile}: 98 | continue 99 | let splat = splitFile(filename) 100 | 101 | # first, look at the whole filename for the purposes of matching 102 | if target != "": 103 | if filename.extractFilename notin viable: 104 | continue 105 | # otherwise, fall back to checking for suitable extension 106 | elif splat.ext notin exts: 107 | continue 108 | 109 | # a 2nd .nimble overrides the first if it matches the directory name 110 | if result.found.isSome: 111 | # if it also isn't clearly the project's .nimble, keep looking 112 | if not isNamedLikeDotNimble(dir, filename): 113 | result = (message: 114 | &"found `{result.found.get}` and `{filename}` in `{dir}`", 115 | found: none(Target)) 116 | continue 117 | 118 | # we found a .nimble; let's set our result and keep looking for a 2nd 119 | result = (message: &"found target in `{dir}` given `{target}`", 120 | found: newTarget(filename).some) 121 | 122 | # this appears to be the best .nimble; let's stop looking here 123 | if isNamedLikeDotNimble(dir, filename): 124 | break 125 | 126 | # we might be good to go, here 127 | if result.found.isSome or not ascend: 128 | return 129 | 130 | # otherwise, maybe we can recurse up the directory tree. 131 | # if our dir is `.`, then we might want to shadow it with a 132 | # full current dir using the supplied proc 133 | 134 | let dir = if dir == ".": cwd() else: dir 135 | 136 | # if we're already at a root, i guess we're done 137 | if dir.isRootDir: 138 | return (message: "", found: none(Target)) 139 | 140 | # else let's see if we have better luck in a parent directory 141 | var 142 | refined = findTargetWith(dir.parentDir, cwd, target = target) 143 | 144 | # return the refinement if it was successful, 145 | if refined.found.isSome: 146 | return refined 147 | 148 | # or if the refinement yields a superior error message 149 | if refined.message != "" and result.message == "": 150 | return refined 151 | 152 | proc findTarget*(dir: string; target = ""): SearchResult = 153 | ## locate one, and only one, nimble file to work upon; dir is where 154 | ## to start looking, target is a .nimble or package name 155 | result = findTargetWith(dir, safeCurrentDir, target = target) 156 | 157 | proc findTarget*(dir: string; target = ""; ascend = true; 158 | extensions: seq[string]): SearchResult = 159 | ## locate one, and only one, nimble file to work upon; dir is where 160 | ## to start looking, target is a .nimble or package name, 161 | ## extensions list optional extensions (such as "nimble") 162 | result = findTargetWith(dir, safeCurrentDir, target = target, 163 | ascend = ascend, extensions = extensions) 164 | 165 | proc createTemporaryFile*(prefix: string; suffix: string): string = 166 | ## it SHOULD create the file, but so far, it only returns the filename 167 | let temp = getTempDir() 168 | result = temp / "bump-" & $getCurrentProcessId() & "-" & prefix & suffix 169 | 170 | proc isValid*(ver: Version): bool = 171 | ## true if the version seems legit 172 | result = ver.major > 0'u or ver.minor > 0'u or ver.patch > 0'u 173 | 174 | proc parseVersion*(nimble: string): Option[Version] = 175 | ## try to parse a version from any line in a .nimble; 176 | ## safe to use at compile-time 177 | for line in nimble.splitLines: 178 | if not line.startsWith("version"): 179 | continue 180 | let 181 | fields = line.split('=') 182 | if fields.len != 2: 183 | continue 184 | var 185 | dotted = fields[1].replace("\"").strip.split('.') 186 | case dotted.len: 187 | of 3: discard 188 | of 2: dotted.add "0" 189 | else: 190 | continue 191 | try: 192 | result = (major: dotted[0].parseUInt, 193 | minor: dotted[1].parseUInt, 194 | patch: dotted[2].parseUInt).some 195 | except ValueError: 196 | discard 197 | 198 | proc bumpVersion*(ver: Version; major, minor, patch = false): Option[Version] = 199 | ## increment the version by the specified metric 200 | if major: 201 | result = (ver.major + 1'u, 0'u, 0'u).some 202 | elif minor: 203 | result = (ver.major, ver.minor + 1'u, 0'u).some 204 | elif patch: 205 | result = (ver.major, ver.minor, ver.patch + 1'u).some 206 | 207 | proc withCrazySpaces*(version: Version; line = ""): string = 208 | ## insert a new version into a line which may have "crazy spaces" 209 | while line != "": 210 | let 211 | verex = line.match re(r"""^version(\s*)=(\s*)"\d+.\d+.\d+"(\s*)""") 212 | if not verex.isSome: 213 | break 214 | let 215 | cap = verex.get.captures.toSeq 216 | (c1, c2, c3) = (cap[0].get, cap[1].get, cap[2].get) 217 | result = &"""version{c1}={c2}"{version}"{c3}""" 218 | return 219 | result = &"""version = "{version}"""" 220 | 221 | proc capture*(exe: string; args: seq[string]; 222 | options: set[ProcessOption]): tuple[output: string; ok: bool] = 223 | ## capture output of a command+args and indicate apparent success 224 | var 225 | command = findExe(exe) 226 | if command == "": 227 | result = (output: &"unable to find executable `{exe}` in path", ok: false) 228 | warn result.output 229 | return 230 | 231 | # we apparently need to escape arguments when using this subprocess form 232 | command &= " " & quoteShellCommand(args) 233 | debug command # let's take a look at those juicy escape sequences 234 | 235 | # run it and get the output to construct our return value 236 | let (output, exit) = execCmdEx(command, options) 237 | result = (output: output, ok: exit == 0) 238 | 239 | # provide a simplified summary at appropriate logging levels 240 | let 241 | ran = exe & " " & args.join(" ") 242 | if result.ok: 243 | info ran 244 | else: 245 | notice ran 246 | 247 | proc capture*(exe: string; args: seq[string]): tuple[output: string; ok: bool] = 248 | ## find and run a given executable with the given arguments; 249 | ## the result includes stdout/stderr and a true value if it seemed to work 250 | result = capture(exe, args, {poStdErrToStdOut, poDaemon, poEvalCommand}) 251 | 252 | proc run*(exe: string; args: varargs[string]): bool = 253 | ## find and run a given executable with the given arguments; 254 | ## the result is true if it seemed to work 255 | var 256 | arguments: seq[string] 257 | for n in args: 258 | arguments.add n 259 | let 260 | caught = capture(exe, arguments) 261 | if not caught.ok: 262 | notice caught.output 263 | result = caught.ok 264 | 265 | proc appearsToBeMasterBranch*(): Option[bool] = 266 | ## try to determine if we're on the `master`/`main` branch 267 | var 268 | caught = capture("git", @["branch", "--show-current"]) 269 | if caught.ok: 270 | result = caught.output.contains(re"(*ANYCRLF)(?m)(?x)^master|main$").some 271 | else: 272 | caught = capture("git", @["branch"]) 273 | if not caught.ok: 274 | notice caught.output 275 | return 276 | result = caught.output.contains(re"(*ANYCRLF)(?m)(?x)^master|main$").some 277 | debug &"appears to be master/main branch? {result.get}" 278 | 279 | proc fetchTagList*(): Option[string] = 280 | ## simply retrieve the tags as a string; attempt to use the 281 | ## later git option to sort the result by version 282 | var 283 | caught = capture("git", @["tag", "--sort=version:refname"]) 284 | if not caught.ok: 285 | caught = capture("git", @["tag", "--list"]) 286 | if not caught.ok: 287 | notice caught.output 288 | return 289 | result = caught.output.strip.some 290 | 291 | proc lastTagInTheList*(tagList: string): string = 292 | ## lazy way to get a tag from the list, whatfer mimicking its V form 293 | let 294 | verex = re("(*ANYCRLF)(?i)(?m)^v?\\.?\\d+\\.\\d+\\.\\d+$") 295 | for match in tagList.findAll(verex): 296 | result = match 297 | if result == "": 298 | raise newException(ValueError, "could not identify a sane tag") 299 | debug &"the last tag in the list is `{result}`" 300 | 301 | proc taggedAs*(version: Version; tagList: string): Option[string] = 302 | ## try to fetch a tag that appears to match a given version 303 | let 304 | escaped = replace($version, ".", "\\.") 305 | verex = re("(*ANYCRLF)(?i)(?m)^v?\\.?" & escaped & "$") 306 | for match in tagList.findAll(verex): 307 | if result.isSome: 308 | debug &"got more than one tag for version {version}:" 309 | debug &"`{result.get}` and `{match}`" 310 | result = none(string) 311 | break 312 | result = match.some 313 | if result.isSome: 314 | debug &"version {version} was tagged as {result.get}" 315 | 316 | proc allTagsAppearToStartWithV*(tagList: string): bool = 317 | ## try to determine if all of this project's tags start with a `v` 318 | let 319 | splat = tagList.splitLines(keepEol = false) 320 | verex = re("(?i)(?x)^v\\.?\\d+\\.\\d+\\.\\d+$") 321 | # if no tags exist, the result is false, right? RIGHT? 322 | if splat.len == 0: 323 | return 324 | for line in splat: 325 | if not line.contains(verex): 326 | debug &"found a tag `{line}` which doesn't use `v`" 327 | return 328 | result = true 329 | debug &"all tags appear to start with `v`" 330 | 331 | proc shouldSearch(folder: string; nimble: string): 332 | Option[tuple[dir: string; file: string]] = 333 | ## given a folder and nimble file (which may be empty), find the most useful 334 | ## directory and target filename to search for. this is a little convoluted 335 | ## because we're trying to replace the function of three options in one proc. 336 | var 337 | dir, file: string 338 | if folder == "": 339 | if nimble != "": 340 | # there's no folder specified, so if a nimble was provided, 341 | # split it into a directory and file for the purposes of search 342 | (dir, file) = splitPath(nimble) 343 | # if the directory portion is empty, search the current directory 344 | if dir == "": 345 | dir = $CurDir # should be correct regardless of os 346 | else: 347 | dir = folder 348 | file = nimble 349 | # by now, we at least know where we're gonna be looking 350 | if not dirExists(dir): 351 | warn &"`{dir}` is not a directory" 352 | return 353 | # try to look for a .nimble file just in case 354 | # we can identify it quickly and easily here 355 | while file != "" and not fileExists(dir / file): 356 | if file.endsWith(dotNimble): 357 | # a file was specified but we cannot find it, even given 358 | # a reasonable directory and the addition of .nimble 359 | warn &"`{dir}/{file}` does not exist" 360 | return 361 | file &= dotNimble 362 | debug &"should search `{dir}` for `{file}`" 363 | result = (dir: dir, file: file).some 364 | 365 | proc pluckVAndDot*(input: string): string = 366 | ## return any `V` or `v` prefix, perhaps with an existing `.` 367 | if input.len == 0 or input[0] notin {'V', 'v'}: 368 | result = "" 369 | elif input[1] == '.': 370 | result = input[0 .. 1] 371 | else: 372 | result = input[0 .. 0] 373 | 374 | proc composeTag*(last: Version; next: Version; v = false; tags = ""): 375 | Option[string] = 376 | ## invent a tag given last and next version, magically adding any 377 | ## needed `v` prefix. fetches tags if a tag list isn't supplied. 378 | var 379 | tag, list: string 380 | 381 | # get the list of tags as a string; boy, i love strings 382 | if tags != "": 383 | list = tags 384 | else: 385 | let 386 | tagList = fetchTagList() 387 | if tagList.isNone: 388 | error &"unable to retrieve tags" 389 | return 390 | list = tagList.get 391 | 392 | let 393 | veeish = allTagsAppearToStartWithV(list) 394 | lastTag = last.taggedAs(list) 395 | 396 | # first, see what the last version was tagged as 397 | if lastTag.isSome: 398 | if lastTag.get.toLowerAscii.startsWith("v"): 399 | # if it starts with `v`, then use `v` similarly 400 | tag = lastTag.get.pluckVAndDot & $next 401 | elif v: 402 | # it didn't start with `v`, but the user wants `v` 403 | tag = "v" & $next 404 | else: 405 | # it didn't start with `v`, so neither should this tag 406 | tag = $next 407 | # otherwise, see if all the prior tags use `v` 408 | elif veeish: 409 | # if all the tags start with `v`, it's a safe bet that we want `v` 410 | # pick the last tag and match its `v` syntax 411 | tag = lastTagInTheList(list).pluckVAndDot & $next 412 | # no history to speak of, but the user asked for `v`; give them `v` 413 | elif v: 414 | tag = "v" & $next 415 | # no history, didn't ask for `v`, so please just don't use `v`! 416 | else: 417 | tag = $next 418 | result = tag.some 419 | debug &"composed the tag `{result.get}`" 420 | 421 | proc bump*(minor = false; major = false; patch = true; release = false; 422 | dry_run = false; folder = ""; nimble = ""; log_level = logLevel; 423 | commit = false; v = false; manual = ""; message: seq[string]): int = 424 | ## the entry point from the cli 425 | var 426 | target: Target 427 | next: Version 428 | last: Option[Version] 429 | 430 | # user's choice, our default 431 | setLogFilter(log_level) 432 | 433 | if folder != "": 434 | warn "the --folder option is deprecated; please use --nimble instead" 435 | 436 | # parse and assign a version number manually provided by the user 437 | if manual != "": 438 | # use our existing parser for consistency 439 | let future = parseVersion(&"""version = "{manual}"""") 440 | if future.isNone or not future.get.isValid: 441 | crash &"unable to parse supplied version `{manual}`" 442 | next = future.get 443 | debug &"user-specified next version as `{next}`" 444 | 445 | # take a stab at whether our .nimble file search might be illegitimate 446 | let search = shouldSearch(folder, nimble) 447 | if search.isNone: 448 | # uh oh; it's not even worth attempting a search 449 | crash &"nothing to bump" 450 | # find the targeted .nimble file 451 | let 452 | sought = findTarget(search.get.dir, target = search.get.file) 453 | if sought.found.isNone: 454 | # emit any available excuse as to why we couldn't find .nimble 455 | if sought.message != "": 456 | warn sought.message 457 | crash &"couldn't pick a {dotNimble} from `{search.get.dir}/{search.get.file}`" 458 | else: 459 | debug sought.message 460 | target = sought.found.get 461 | 462 | # if we're not on the master/main branch, let's just bail for now 463 | let 464 | branch = appearsToBeMasterBranch() 465 | if branch.isNone: 466 | crash "uh oh; i cannot tell if i'm on the master/main branch" 467 | elif not branch.get: 468 | crash "i'm afraid to modify any branch that isn't master/main" 469 | else: 470 | debug "good; this appears to be the master/main branch" 471 | 472 | # make a temp file in an appropriate spot, with a significant name 473 | let 474 | temp = createTemporaryFile(target.package, dotNimble) 475 | debug &"writing {temp}" 476 | # but remember to remove the temp file later 477 | defer: 478 | debug &"removing {temp}" 479 | if not tryRemoveFile(temp): 480 | warn &"unable to remove temporary file `{temp}`" 481 | 482 | block writing: 483 | # open our temp file for writing 484 | var 485 | writer = temp.open(fmWrite) 486 | # but remember to close the temp file in any event 487 | defer: 488 | writer.close 489 | for line in lines($target): 490 | if not line.contains(re"^version\s*="): 491 | writer.writeLine line 492 | continue 493 | 494 | # parse the current version number 495 | last = line.parseVersion 496 | if last.isNone: 497 | crash &"unable to parse version from `{line}`" 498 | else: 499 | debug "current version is", last.get 500 | 501 | # if we haven't set the new version yet, bump the version number 502 | if not next.isValid: 503 | let 504 | bumped = last.get.bumpVersion(major, minor, patch) 505 | if bumped.isNone: 506 | crash "version unchanged; specify major, minor, or patch" 507 | else: 508 | debug "next version is", bumped.get 509 | next = bumped.get 510 | 511 | # make a subtle edit to the version string and write it out 512 | writer.writeLine next.withCrazySpaces(line) 513 | 514 | # for sanity, make sure we were able to parse the previous version 515 | if last.isNone: 516 | crash &"couldn't find a version statement in `{target}`" 517 | 518 | # and check again to be certain that our next version is valid 519 | if not next.isValid: 520 | crash &"unable to calculate the next version; `{next}` invalid" 521 | 522 | # move to the repo so we can do git operations 523 | debug "changing directory to", target.repo 524 | setCurrentDir(target.repo) 525 | 526 | # compose a new tag 527 | let 528 | composed = composeTag(last.get, next, v = v) 529 | if composed.isNone: 530 | crash "i can't safely guess at enabling `v`; try a manual tag first?" 531 | let 532 | tag = composed.get 533 | 534 | # make a git commit message 535 | var msg = tag 536 | if message.len > 0: 537 | msg &= ": " & message.join(" ") 538 | 539 | # cheer 540 | fatal &"🎉{msg}" 541 | 542 | if dry_run: 543 | debug "dry run and done" 544 | return 545 | 546 | # copy the new .nimble over the old one 547 | try: 548 | debug &"copying {temp} over {target}" 549 | copyFile(temp, $target) 550 | except Exception as e: 551 | discard e # noqa 😞 552 | crash &"failed to copy `{temp}` to `{target}`: {e.msg}" 553 | 554 | # try to do some git operations 555 | block nimgitsfu: 556 | # commit just the .nimble file, or the whole repository 557 | let 558 | committee = if commit: target.repo else: $target 559 | if not run("git", "commit", "-m", msg, committee): 560 | break 561 | 562 | # if a message exists, omit the tag from the message 563 | if message.len > 0: 564 | msg = message.join(" ") 565 | 566 | # tag the commit with the new version and message 567 | if not run("git", "tag", "-a", "-m", msg, tag): 568 | break 569 | 570 | # push the commits 571 | if not run("git", "push"): 572 | break 573 | 574 | # push the tags 575 | if not run("git", "push", "--tags"): 576 | break 577 | 578 | # we might want to use hub to mark a github release 579 | if release: 580 | if not run("hub", "release", "create", "-m", msg, tag): 581 | break 582 | 583 | # celebrate 584 | fatal "🍻bumped" 585 | return 0 586 | 587 | # hang our head in shame 588 | fatal "🐼nimgitsfu fail" 589 | return 1 590 | 591 | proc projectVersion*(hint = ""): Option[Version] {.compileTime.} = 592 | ## try to get the version from the current (compile-time) project 593 | let 594 | target = findTargetWith(macros.getProjectPath(), safeCurrentDir, hint) 595 | 596 | if target.found.isNone: 597 | macros.warning target.message 598 | macros.error &"provide the name of your project, minus {dotNimble}" 599 | var 600 | nimble = staticRead $target.found.get 601 | if nimble == "": 602 | macros.error &"missing/empty {dotNimble}; what version is this?!" 603 | result = parseVersion(nimble) 604 | 605 | when isMainModule: 606 | import cligen 607 | 608 | let 609 | logger = newCuteConsoleLogger() 610 | addHandler(logger) 611 | 612 | const logo = """ 613 | 614 | __ 615 | / /_ __ ______ ___ ____ 616 | / __ \/ / / / __ `__ \/ __ \ 617 | / /_/ / /_/ / / / / / / /_/ / 618 | /_.___/\__,_/_/ /_/ /_/ .___/ 619 | /_/ 620 | 621 | Increment the version of a nimble package, tag it, and push it via git 622 | 623 | Usage: 624 | bump [optional-params] [message: string...] 625 | 626 | """ 627 | # find the version of bump itself, whatfer --version reasons 628 | const 629 | version = projectVersion() 630 | if version.isSome: 631 | clCfg.version = $version.get 632 | else: 633 | clCfg.version = "(unknown version)" 634 | 635 | dispatchCf bump, cmdName = "bump", cf = clCfg, noHdr = true, 636 | usage = logo & "Options(opt-arg sep :|=|spc):\n$options", 637 | help = { 638 | "patch": "increment the patch version field", 639 | "minor": "increment the minor version field", 640 | "major": "increment the major version field", 641 | "dry-run": "just report the projected version", 642 | "commit": "also commit any other unstaged changes", 643 | "v": "prefix the version tag with an ugly `v`", 644 | "nimble": "specify the nimble file to modify", 645 | "folder": "specify the location of the nimble file", 646 | "release": "also use `hub` to issue a GitHub release", 647 | "log-level": "specify Nim logging level", 648 | "manual": "manually set the new version to #.#.#", 649 | } 650 | -------------------------------------------------------------------------------- /bump.nim.cfg: -------------------------------------------------------------------------------- 1 | --define:cutelogEmojis 2 | -------------------------------------------------------------------------------- /bump.nimble: -------------------------------------------------------------------------------- 1 | version = "1.8.33" 2 | author = "disruptek" 3 | description = "a tiny tool to bump nimble versions" 4 | license = "MIT" 5 | 6 | requires "https://github.com/disruptek/cutelog >= 2.0.0 & < 3.0.0" 7 | requires "https://github.com/disruptek/cligen >= 2.0.2 & < 3.0.0" 8 | 9 | bin = @["bump"] 10 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.* 3 | !*.nim 4 | -------------------------------------------------------------------------------- /tests/rando/blue.nimble: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disruptek/bump/09811e775a94a77bb4c6d25349109fcf8341db92/tests/rando/blue.nimble -------------------------------------------------------------------------------- /tests/rando/red.nimble: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/disruptek/bump/09811e775a94a77bb4c6d25349109fcf8341db92/tests/rando/red.nimble -------------------------------------------------------------------------------- /tests/tbump.nim: -------------------------------------------------------------------------------- 1 | import std/os 2 | import std/strutils 3 | import std/options 4 | 5 | import bump 6 | import balls 7 | 8 | suite "tests of bump": 9 | let 10 | ver123 {.used.} = (major: 1'u, minor: 2'u, patch: 3'u) 11 | ver155 {.used.} = (major: 1'u, minor: 5'u, patch: 5'u) 12 | ver170 {.used.} = (major: 1'u, minor: 7'u, patch: 0'u) 13 | ver180 {.used.} = (major: 1'u, minor: 8'u, patch: 0'u) 14 | ver1819 {.used.} = (major: 1'u, minor: 8'u, patch: 19'u) 15 | ver171 {.used.} = (major: 1'u, minor: 7'u, patch: 1'u) 16 | ver456 {.used.} = (major: 4'u, minor: 5'u, patch: 6'u) 17 | ver457 {.used.} = (major: 4'u, minor: 5'u, patch: 7'u) 18 | ver789 {.used.} = (major: 7'u, minor: 8'u, patch: 9'u) 19 | ver799 {.used.} = (major: 7'u, minor: 9'u, patch: 9'u) 20 | aList {.used.} = "" 21 | bList {.used.} = """ 22 | v.1.2.3 23 | V.4.5.6 24 | v7.8.9 25 | V10.11.12 26 | """.unindent.strip 27 | cList {.used.} = """ 28 | v.1.2.3 29 | 4.5.6 30 | v7.8.9 31 | V10.11.12 32 | 12.13.14 33 | """.unindent.strip 34 | crazy {.used.} = @[ 35 | """version="1.2.3"""", 36 | """version = "1.2.3"""", 37 | """version = "1.2.3" """, 38 | """version = "1.2.3"""", 39 | ] 40 | 41 | test "parse version statement": 42 | for c in crazy: 43 | check ver123 == c.parseVersion.get 44 | 45 | test "substitute version into line with crazy spaces": 46 | for c in crazy: 47 | check ver123.withCrazySpaces(c) == c 48 | check ver123.withCrazySpaces("""version="4.5.6"""") == crazy[0] 49 | 50 | test "are we on the master branch": 51 | let 52 | isMaster = appearsToBeMasterBranch() 53 | check isMaster.isSome 54 | #check isMaster.get 55 | 56 | test "all tags appear to start with v": 57 | check bList.allTagsAppearToStartWithV 58 | check not cList.allTagsAppearToStartWithV 59 | check not aList.allTagsAppearToStartWithV 60 | 61 | test "identify tags for arbitrary versions": 62 | let 63 | tagList = fetchTagList() 64 | isTagged {.used.} = ver1819.taggedAs(tagList.get) 65 | notTagged {.used.} = ver155.taggedAs(tagList.get) 66 | check isTagged.isSome and isTagged.get == "1.8.19" 67 | check notTagged.isNone 68 | 69 | test "last tag in the tag list": 70 | try: 71 | discard aList.lastTagInTheList 72 | check false, "expected a value error" 73 | except ValueError: 74 | check true, "got a value error as anticipated" 75 | check bList.lastTagInTheList == "V10.11.12" 76 | check cList.lastTagInTheList == "12.13.14" 77 | 78 | test "compose the right tag given strange input": 79 | let 80 | tagv171 {.used.} = composeTag(ver170, ver171, v = true, tags = aList) 81 | tag171 {.used.} = composeTag(ver170, ver171, v = false, tags = aList) 82 | tagv457 {.used.} = composeTag(ver456, ver457, tags = bList) 83 | tagv799 {.used.} = composeTag(ver789, ver799, tags = cList) 84 | tagv456 {.used.} = composeTag(ver123, ver456, tags = cList) 85 | tag457 {.used.} = composeTag(ver155, ver457, tags = cList) 86 | tagv155 {.used.} = composeTag(ver799, ver155, tags = bList) 87 | check tagv171.get == "v1.7.1" 88 | check tag171.get == "1.7.1" 89 | check tagv457.get == "V.4.5.7" 90 | check tagv799.get == "v7.9.9" 91 | check tagv456.get == "v.4.5.6" 92 | check tag457.get == "4.5.7" 93 | check tagv155.get == "V1.5.5" 94 | 95 | test "version validity checks out": 96 | check (0'u, 0'u, 0'u).isValid == false 97 | check (0'u, 0'u, 1'u).isValid == true 98 | 99 | test "strange user-supplied versions do not parse": 100 | check parseVersion("""version = "-1.2.3"""").isNone 101 | check parseVersion("""version = "123"""").isNone 102 | check parseVersion("""version = "steve"""").isNone 103 | check parseVersion("""version = "v0.3.0"""").isNone 104 | 105 | test "strange user-supplied versions that DO parse": 106 | check $parseVersion("""version = "12.3"""").get == "12.3.0" 107 | 108 | test "find a version at compile-time": 109 | const 110 | version = projectVersion() 111 | check version.isSome 112 | check $(version.get) != "0.0.0" 113 | 114 | test "find a nimble file from below": 115 | setCurrentDir(parentDir(currentSourcePath())) 116 | check fileExists(extractFilename(currentSourcePath())) 117 | let 118 | version = projectVersion() 119 | check version.isSome 120 | check $(version.get) != "0.0.0" 121 | let 122 | bumpy = findTarget(".", target = "bump") 123 | easy = findTarget(".", target = "") 124 | missing = findTarget("missing", target = "") 125 | randoR = findTarget("../tests/rando", target = "red") 126 | randoB = findTarget("../tests/rando", target = "blue") 127 | randoG = findTarget("../tests/rando", target = "green") 128 | for search in [bumpy, easy]: 129 | checkpoint search.message 130 | check search.found.isSome 131 | check search.found.get.repo.dirExists 132 | check search.found.get.package == "bump" 133 | check search.found.get.ext == ".nimble" 134 | for search in [missing, randoG]: 135 | checkpoint search.message 136 | check search.found.isNone 137 | for search in [randoR, randoB]: 138 | checkpoint search.message 139 | check search.found.isSome 140 | check randoR.found.isSome 141 | check randoR.found.get.package == "red" 142 | check randoB.found.isSome 143 | check randoB.found.get.package == "blue" 144 | 145 | test "version comparison": 146 | check ver123 < ver155 147 | check ver170 > ver123 148 | check ver171 > ver170 149 | check ver170 < ver171 150 | check ver456 > ver170 151 | check ver170 != (1'u, 2'u, 3'u) 152 | check ver170 == (1'u, 7'u, 0'u) 153 | check ver170 <= (1'u, 7'u, 0'u) 154 | check ver170 >= (1'u, 7'u, 0'u) 155 | --------------------------------------------------------------------------------