Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,70 @@ refactor!: drop support for Node 6

If no keywords are specified a **Patch** bump is applied.

### Scheme: Custom (YAML)

A user-defined scheme can be supplied via a YAML file using the `--scheme-file` flag. This is
useful when neither the autotag nor Conventional Commits scheme fits your project's commit
conventions.

`--scheme-file` is mutually exclusive with an explicitly non-default `--scheme`: pass one or the
other. Ready-to-copy reference schemes are available under
[`examples/schemes/`](examples/schemes/).

#### YAML schema

```yaml
name: my-custom-scheme # required: identifier used in logs
description: Optional summary. # optional: free-form

# Rules are evaluated in declaration order. The first rule whose regex matches
# the commit message determines the bump. Remaining rules are not consulted.
rules:
- name: breaking-footer # optional: shown in validation errors
match: '(?m)^BREAKING CHANGE:'
bump: major
- name: feat
match: '^feat(\([^)]*\))?:'
bump: minor
- name: patch-types
match: '^(fix|chore|docs|refactor)(\([^)]*\))?:'
bump: patch

# Required. What to do when no rule matches a commit.
# Allowed values: major | minor | patch | none
default: patch
```

- `match` is a Go [regexp](https://pkg.go.dev/regexp/syntax) pattern.
- `bump` and `default` must be one of `major`, `minor`, `patch`, or `none`.
- A rule with `bump: none` matches the commit but contributes no version change — useful for
explicitly ignoring commits (e.g., merge commits) without falling through to the default.
- `default: none` combined with `--strict-match` will cause unmatched commits to error out instead
of being silently ignored.

All validation (regex compilation, allowed bump values, required fields, unknown keys) happens at
load time, so a malformed scheme fails fast with a clear error before any commits are parsed.

#### Example

Given a scheme file `./my-scheme.yaml`:

```yaml
name: my-scheme
rules:
- match: '(?m)^BREAKING CHANGE:'
bump: major
- match: '^feat:'
bump: minor
default: patch
```

Invoke autotag with:

```sh
autotag --scheme-file ./my-scheme.yaml
```

### Strict Match Option

The `--strict-match` option enforces that commit messages must strictly adhere to the specified commit message scheme.
Expand All @@ -164,6 +228,13 @@ When the `--strict-match` option is enabled, the behavior of the commit message
- With `--strict-match`: Commit messages that do not follow the conventional commit format will result in an
error, and the commit will not be processed.

- **Custom (YAML) Scheme**:
- Without `--strict-match`: Unmatched commits use the scheme's `default` value. When `default:
none`, those commits contribute no version change.
- With `--strict-match`: Unmatched commits (no rule matches **and** the scheme declares `default:
none`) result in an error. If the scheme's `default` is `major`, `minor`, or `patch`, the
fallback is always taken and strict-match never fires.

#### Usage

To use the strict match option, add the `--strict-match` flag when running the autotag tool:
Expand Down
93 changes: 31 additions & 62 deletions autotag.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ type GitRepoConfig struct {
// * https://www.conventionalcommits.org/en/v1.0.0/#summary w
Scheme string

// SchemeFile is an optional path to a YAML file defining a custom scheme.
// When set, it takes precedence over Scheme. Passing a non-default Scheme
// alongside SchemeFile is an error.
SchemeFile string

// Prefix prepends literal 'v' to the tag, eg: v1.0.0. Enabled by default
Prefix bool

Expand Down Expand Up @@ -153,7 +158,7 @@ type GitRepo struct {
preReleaseTimestampLayout string
buildMetadata string

scheme string
scheme Scheme
strictMatch bool

prefix bool
Expand Down Expand Up @@ -209,13 +214,18 @@ func NewRepo(cfg GitRepoConfig) (*GitRepo, error) {
}
}

scheme, err := resolveScheme(cfg)
if err != nil {
return nil, err
}

r := &GitRepo{
repo: repo,
branch: cfg.Branch,
preReleaseName: cfg.PreReleaseName,
preReleaseTimestampLayout: cfg.PreReleaseTimestampLayout,
buildMetadata: cfg.BuildMetadata,
scheme: cfg.Scheme,
scheme: scheme,
prefix: cfg.Prefix,
strictMatch: cfg.StrictMatch,
buildNumber: cfg.BuildNumber,
Expand Down Expand Up @@ -524,82 +534,41 @@ func (r *GitRepo) tagNewVersion() error {

// parseCommit looks at HEAD commit see if we want to increment major/minor/patch
func (r *GitRepo) parseCommit(commit *git.Commit) (*version.Version, error) {
var b bumper
msg := commit.Message
log.Printf("Parsing %s: %s\n", commit.ID, msg)

switch r.scheme {
case "conventional":
b = parseConventionalCommit(msg, r.strictMatch)
case "", "autotag":
b = parseAutotagCommit(msg)
}
b := r.scheme.ParseCommit(msg)

if r.strictMatch && b == nil {
return nil, fmt.Errorf("no match found for commit %s", commit.ID)
}

// fallback to patch bump if no matches from the scheme parsers
if b != nil {
return b.bump(r.currentVersion)
}

return nil, nil
}

// parseAutotagCommit implements the autotag (default) commit scheme.
// A git commit message header containing:
// - [major] or #major: major version bump
// - [minor] or #minor: minor version bump
// - [patch] or #patch: patch version bump
//
// If no action is present nil is returned and the caller must decide what action to take.
func parseAutotagCommit(msg string) bumper {
if majorRex.MatchString(msg) {
log.Println("major bump")
return majorBumper
}

if minorRex.MatchString(msg) {
log.Println("minor bump")
return minorBumper
}

if patchRex.MatchString(msg) {
log.Println("patch bump")
return patchBumper
}

return nil
}

// parseConventionalCommit implements the Conventional Commit scheme. Given a commit message
// A strict match option will enforce that the commit message must match the conventional commit
// it will return the correct version bumper. In the case of non-confirming conventional commit
// it will return nil and the caller will decide what action to take.
// https://www.conventionalcommits.org/en/v1.0.0/#summary
func parseConventionalCommit(msg string, strictMatch bool) bumper {
matches := findNamedMatches(conventionalCommitRex, msg)

// If we're in strict match and no matches are found, return nil
bumperType, authorized := conventionalCommitAuthorizedTypes[matches["type"]]
if strictMatch && !authorized {
return nil
}

// If the commit contains a footer with 'BREAKING CHANGE:' it is always a major bump
if strings.Contains(msg, "\nBREAKING CHANGE:") {
return majorBumper
// resolveScheme maps the user-facing GitRepoConfig fields to a Scheme
// implementation. When SchemeFile is set, it is loaded and returned; the
// built-in Scheme string must then be empty or the default "autotag" (the
// CLI's default value), otherwise the two are considered mutually exclusive.
// Unknown built-in scheme names are an error.
func resolveScheme(cfg GitRepoConfig) (Scheme, error) {
if cfg.SchemeFile != "" {
if cfg.Scheme != "" && cfg.Scheme != "autotag" {
return nil, fmt.Errorf("--scheme and --scheme-file are mutually exclusive")
}
return LoadSchemeFile(cfg.SchemeFile)
}

// If the type/scope in the header includes a trailing '!' this is a breaking change
if breaking, ok := matches["breaking"]; ok && breaking == "!" {
return majorBumper
switch cfg.Scheme {
case "", "autotag":
return autotagScheme{}, nil
case "conventional":
return conventionalScheme{strictMatch: cfg.StrictMatch}, nil
default:
return nil, fmt.Errorf("unknown scheme %q", cfg.Scheme)
}

// If the type in the header match a type try to find it in the authorized list
// If it's not in the list it returns nil
return bumperType
}

// MajorBump will bump the version one major rev 1.0.0 -> 2.0.0
Expand Down
2 changes: 2 additions & 0 deletions autotag/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Options struct {
PreReleaseTimestamp string `short:"T" long:"pre-release-timestamp" description:"create a pre-release tag and append a timestamp (can be: datetime|epoch)"`
BuildMetadata string `short:"m" long:"build-metadata" description:"optional SemVer build metadata to append to the version with '+' character"`
Scheme string `short:"s" long:"scheme" description:"The commit message scheme to use (can be: autotag|conventional)" default:"autotag"`
SchemeFile string `long:"scheme-file" description:"Path to a YAML file defining a custom scheme (mutually exclusive with --scheme)"`
NoVersionPrefix bool `short:"e" long:"empty-version-prefix" description:"Do not prepend v to version tag"`
StrictMatch bool `long:"strict-match" description:"Enforce strict mode on the scheme parsers, returns error if no match is found"`
BuildNumber bool `long:"build-number" description:"Enforce append build number in metadata (after '+' character), returns error if metadata is not a unsigned integer or empty"`
Expand Down Expand Up @@ -48,6 +49,7 @@ func main() {
PreReleaseTimestampLayout: opts.PreReleaseTimestamp,
BuildMetadata: opts.BuildMetadata,
Scheme: opts.Scheme,
SchemeFile: opts.SchemeFile,
Prefix: !opts.NoVersionPrefix,
StrictMatch: opts.StrictMatch,
BuildNumber: opts.BuildNumber,
Expand Down
81 changes: 81 additions & 0 deletions autotag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ type testRepoSetup struct {
// (optional) versioning scheme to use, eg: "" or "autotag", "conventional". If not set, defaults to "" (autotag)
scheme string

// (optional) path to a YAML file defining a custom scheme. When set, takes precedence over scheme
// (unless scheme is also explicitly set to a non-default value, in which case NewRepo returns an error).
schemeFile string

// (optional) branch to create. If not set, defaults to "master"
branch string

Expand Down Expand Up @@ -105,6 +109,7 @@ func newTestRepo(t *testing.T, setup testRepoSetup) (GitRepo, error) {
PreReleaseTimestampLayout: setup.preReleaseTimestampLayout,
BuildMetadata: setup.buildMetadata,
Scheme: setup.scheme,
SchemeFile: setup.schemeFile,
Prefix: !setup.disablePrefix,
StrictMatch: setup.strictMatch,
BuildNumber: setup.buildNumber,
Expand Down Expand Up @@ -345,6 +350,17 @@ func TestNewRepoStrictMatch(t *testing.T) {
strictMatch: true,
},
},

// tests for custom YAML schemes
{
name: "custom scheme with default none, no match fails with strict match",
setup: testRepoSetup{
schemeFile: "testdata/schemes/autotag_clone.yaml",
initialTag: "v1.0.0",
nextCommit: "random commit with no marker",
strictMatch: true,
},
},
}

for _, tc := range tests {
Expand All @@ -355,6 +371,24 @@ func TestNewRepoStrictMatch(t *testing.T) {
}
}

func TestNewRepoSchemeMutuallyExclusive(t *testing.T) {
_, err := newTestRepo(t, testRepoSetup{
scheme: "conventional",
schemeFile: "testdata/schemes/valid_minimal.yaml",
initialTag: "v1.0.0",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
}

func TestNewRepoSchemeFileLoadError(t *testing.T) {
_, err := newTestRepo(t, testRepoSetup{
schemeFile: "testdata/schemes/does_not_exist.yaml",
initialTag: "v1.0.0",
})
assert.Error(t, err)
}

func TestMajor(t *testing.T) {
r, err := newTestRepo(t, testRepoSetup{
branch: "master",
Expand Down Expand Up @@ -786,6 +820,53 @@ func TestAutoTag(t *testing.T) {
},
expectedTag: "v0.10.0",
},

// tests for custom YAML schemes
{
name: "custom scheme, rule match bumps minor",
setup: testRepoSetup{
schemeFile: "testdata/schemes/valid_minimal.yaml",
nextCommit: "feat: add thing",
initialTag: "v1.0.0",
},
expectedTag: "v1.1.0",
},
{
name: "custom scheme, no match falls back to default patch",
setup: testRepoSetup{
schemeFile: "testdata/schemes/valid_minimal.yaml",
nextCommit: "random commit message",
initialTag: "v1.0.0",
},
expectedTag: "v1.0.1",
},
{
name: "custom scheme, autotag_clone replicates built-in autotag",
setup: testRepoSetup{
schemeFile: "testdata/schemes/autotag_clone.yaml",
commitList: []string{
"[minor] thing 1",
"[major] break thing 1",
"[minor] thing 2",
},
initialTag: "v1.0.0",
},
expectedTag: "v2.0.0",
},
{
name: "custom scheme, conventional_clone replicates built-in conventional",
setup: testRepoSetup{
schemeFile: "testdata/schemes/conventional_clone.yaml",
commitList: []string{
"feat: thing 1",
"feat!: break thing 1",
"feat: thing 2",
"refactor(runtime)!: drop support for Node 6",
},
initialTag: "v1.0.0",
},
expectedTag: "v2.0.0",
},
}

for _, tc := range tests {
Expand Down
20 changes: 20 additions & 0 deletions examples/schemes/autotag.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Reproduces the built-in "autotag" scheme as a --scheme-file.
# Use this as a starting point when customizing the bracket/hash keyword style.
name: autotag
description: Bump based on [major]/[minor]/[patch] or #major/#minor/#patch markers.

rules:
- name: major
match: '(?i)\[major\]|#major'
bump: major
- name: minor
match: '(?i)\[minor\]|#minor'
bump: minor
- name: patch
match: '(?i)\[patch\]|#patch'
bump: patch

# The built-in autotag scheme falls back to a patch bump when no marker is
# present. Use `default: none` if you'd rather unmatched commits contribute
# nothing (combine with --strict-match to turn them into errors).
default: patch
Loading