Learning Objectives

By the end of this module, you will be able to:

  1. Explain Semantic Versioning (semver) and how MAJOR.MINOR.PATCH communicates the impact of a change
  2. Write commit messages following the Conventional Commits specification
  3. Distinguish between commit types (feat, fix, docs, chore, etc.) and understand their impact on versioning
  4. Use Git tags to mark releases — both lightweight and annotated — and push them to remotes
  5. Set up automated changelog generation with tools like standard-version and release-please
  6. Create GitHub/GitLab releases from tagged commits

1. Semantic Versioning (semver)

In 2011, Tom Preston-Werner (co-founder of GitHub) formalized a convention for versioning software. It became one of the most widely adopted standards in the industry.

The Three Numbers

MAJOR . MINOR . PATCH
  2   .   4   .   1

  │       │       └── Bug fixes (backwards compatible)
  │       └────────── New features (backwards compatible)
  └────────────────── Breaking changes (NOT backwards compatible)

The Rules

Change TypeVersion BumpExampleSignal to Users
Bug fix (backwards compatible)PATCH2.4.12.4.2Safe to upgrade immediately
New feature (backwards compatible)MINOR2.4.22.5.0Safe, but review new features
Breaking changeMAJOR2.5.03.0.0Review carefully, may need code changes

When you bump:

  • PATCH: increment the last number (2.4.12.4.2)
  • MINOR: increment the middle number, reset patch to 0 (2.4.22.5.0)
  • MAJOR: increment the first number, reset minor and patch to 0 (2.5.03.0.0)

What "Breaking" Means

A breaking change modifies the public API in a way that existing users' code may stop working:

  • Removing or renaming a function/endpoint
  • Changing a function's signature (parameters, return type)
  • Changing default behavior
  • Removing a configuration option
  • Changing the format of output data

A non-breaking change is backwards compatible — existing code continues to work:

  • Adding a new function/endpoint
  • Adding an optional parameter with a default value
  • Fixing a bug (unless people depended on the buggy behavior)
  • Internal refactoring with no API changes

The 0.x.x Special Case

Versions starting with 0 (e.g., 0.3.1) are considered pre-release. The API is not yet stable, and breaking changes can happen in any release without bumping to 1.0.0. Once you publish 1.0.0, you're signaling that the API is stable and semver rules apply strictly.

Pre-release and Build Metadata

1.2.3-alpha.1        ← pre-release identifier
1.2.3-beta.2         ← another pre-release
1.2.3-rc.1           ← release candidate
1.2.3+build.456      ← build metadata (ignored in versioning)
1.2.3-alpha.1+001    ← both combined

Pre-release versions have lower precedence than the normal version: 1.0.0-alpha < 1.0.0.

Why semver Matters

Scenario: You depend on library "auth-utils" at version 2.4.1

New version available: 2.4.2 (patch)
→ Bug fix only. Upgrade without worry.

New version available: 2.5.0 (minor)
→ New features added. Your code still works. Upgrade when convenient.

New version available: 3.0.0 (major)
→ Breaking changes! Read the changelog. Test thoroughly before upgrading.

Semver gives users a signal about the risk of upgrading. It requires discipline from maintainers to classify changes correctly.


2. Conventional Commits Specification

Conventional Commits originated from the AngularJS contribution guidelines and has become a widely adopted standard. It adds structure to commit messages that is both human and machine readable.

The Format

<type>(<optional scope>): <description>

<optional body>

<optional footer(s)>

A Complete Example

feat(auth): add OAuth2 login support

Implements Google and GitHub OAuth2 providers.
Users can now sign in with their existing accounts
instead of creating new credentials.

The login page shows provider buttons above the
email/password form.

BREAKING CHANGE: the /api/login endpoint now returns
a different response shape with a "provider" field.

Closes #234

The Type

The type is mandatory and must be one of:

TypeDescriptionVersion Impact
featA new featureBumps MINOR
fixA bug fixBumps PATCH
docsDocumentation changes onlyNo version bump
styleFormatting, whitespace, semicolons (no code change)No version bump
refactorCode restructuring (no feature or fix)No version bump
perfPerformance improvementBumps PATCH
testAdding or fixing testsNo version bump
buildBuild system or external dependenciesNo version bump
ciCI/CD configuration changesNo version bump
choreMaintenance tasks (no src or test change)No version bump
revertReverting a previous commitDepends on reverted type

Only feat and fix directly affect the version number. Other types are for organization and changelog clarity.

The Scope

The scope is optional and describes the section of the codebase affected:

feat(auth): add login form
fix(api): handle null response
docs(readme): update installation steps
refactor(database): extract query builder

Scopes are typically nouns: auth, api, ui, database, config, core, etc.

The Description

The description follows Git's native commit message guidelines:

  • Imperative tone: "add feature" not "added feature"
  • Lowercase first letter: "add" not "Add"
  • No period at the end: "add login form" not "add login form."
  • Concise: aim for under 72 characters total (including type and scope)

The Body

The body is optional and provides additional context:

feat: add email notification system

Integrates with SendGrid API to send transactional emails.
Supports templates for welcome, password reset, and
order confirmation emails.

Rate limiting is applied at 100 emails per minute
to comply with SendGrid's free tier.

Separate the body from the description with a blank line. The body can use full sentences, paragraphs, and bullet points.

The footer is optional and is used for:

Breaking changes:

BREAKING CHANGE: the login endpoint now requires
a "provider" field in the request body.

Issue references:

Closes #123
Fixes #456
Refs #789

Multiple footers:

BREAKING CHANGE: removed deprecated v1 API endpoints
Reviewed-by: Alice Smith
Closes #234, #567

The ! Shorthand for Breaking Changes

You can mark a breaking change directly in the type line with !:

feat!: remove support for Node 12

BREAKING CHANGE: minimum Node version is now 16.

Or with a scope:

feat(api)!: change response format for /users endpoint

The ! is a visual alert. If present, you should still include BREAKING CHANGE: in the footer for a detailed description.


3. Conventional Commits + Semver Connection

The power of conventional commits is that version bumping becomes automatic — tools can read your commit messages and determine the correct version.

Commits since last release:
  fix(auth): resolve session timeout issue        → PATCH
  docs: update API reference                      → (none)
  feat(api): add /users/search endpoint           → MINOR
  chore: update dev dependencies                  → (none)
  fix(ui): correct button alignment               → PATCH

Highest impact: MINOR (from the feat commit)
Version bump: 2.4.1 → 2.5.0

If any commit has BREAKING CHANGE or !:

  feat!: redesign authentication flow             → MAJOR
  fix: correct error messages                     → PATCH

Highest impact: MAJOR (from the breaking change)
Version bump: 2.5.0 → 3.0.0

The highest impact commit determines the version bump. A single breaking change overrides all minor and patch changes.


4. Git Tags for Releases

Tags are the mechanism Git uses to mark specific commits as releases. They're pointers that don't move (unlike branches).

Lightweight vs. Annotated Tags

Lightweight tag:
  Just a pointer to a commit (like a branch that never moves)
  No extra metadata

Annotated tag:
  A full Git object with:
  - Tagger name and email
  - Date
  - Message
  - GPG signature (optional)

Always use annotated tags for releases. Lightweight tags are for personal bookmarks.

Creating Tags

# Annotated tag (recommended for releases)
git tag -a v1.0.0 -m "Version 1.0.0 - Initial release"
 
# Annotated tag with a longer message (opens editor)
git tag -a v2.0.0
 
# Lightweight tag (for personal use)
git tag v1.0.0-temp
 
# Tag a specific commit (not just HEAD)
git tag -a v1.0.0 abc1234 -m "Version 1.0.0"
 
# Create a tag with -m implies annotated (no -a needed)
git tag v1.0.0 -m "Version 1.0.0"

Listing and Inspecting Tags

# List all tags
git tag
 
# List tags matching a pattern
git tag -l "v1.*"
git tag -l "v2.0.*"
 
# Show tag details
git show v1.0.0
 
# Show tag type (tag = annotated, commit = lightweight)
git cat-file -t v1.0.0
 
# List tags with dates
git tag -l --format='%(refname:short) %(creatordate:short)'

Pushing Tags

Tags are not pushed by default. You must push them explicitly:

# Push a specific tag
git push origin v1.0.0
 
# Push all tags
git push origin --tags
 
# Push only annotated tags (recommended)
git push origin --follow-tags

--follow-tags is the safest option: it pushes annotated tags that are reachable from the commits being pushed, skipping lightweight tags.

Deleting Tags

# Delete locally
git tag -d v1.0.0
 
# Delete from remote
git push origin --delete v1.0.0
 
# Delete multiple remote tags
git push origin --delete v1.0.0 v1.0.1 v1.0.2

Checking Out a Tag

# Puts you in "detached HEAD" state
git checkout v1.0.0
 
# Better: create a branch from a tag
git checkout -b hotfix/v1.0.1 v1.0.0

Configuring Auto-Push for Tags

# Always push annotated tags with git push
git config --global push.followTags true

With this setting, git push automatically pushes reachable annotated tags alongside your commits.


5. Automated Changelog Generation

The real payoff of conventional commits: tools can automatically generate human-readable changelogs from your commit history.

standard-version (Now Deprecated — Use release-please)

standard-version was the go-to tool for automated releases. It's now in maintenance mode, but understanding it helps you understand the concept.

npm install --save-dev standard-version

What standard-version does in one command:

  1. Reads commit history since the last tag
  2. Determines the version bump (MAJOR/MINOR/PATCH) from commit types
  3. Updates CHANGELOG.md with grouped, formatted entries
  4. Bumps the version in package.json
  5. Creates a commit: chore(release): 2.5.0
  6. Creates a Git tag: v2.5.0
# First release
npx standard-version --first-release
 
# Subsequent releases (auto-determines version)
npx standard-version
 
# Force a specific bump
npx standard-version --release-as major
npx standard-version --release-as minor
npx standard-version --release-as patch
 
# Force a specific version
npx standard-version --release-as 3.0.0
 
# Preview without making changes
npx standard-version --dry-run

Generated Changelog

# Changelog
 
## [2.5.0](https://github.com/user/repo/compare/v2.4.1...v2.5.0) (2024-03-15)
 
### Features
 
* **auth:** add OAuth2 login support ([abc1234](https://github.com/user/repo/commit/abc1234))
* **api:** add user search endpoint ([def5678](https://github.com/user/repo/commit/def5678))
 
### Bug Fixes
 
* **ui:** correct button alignment on mobile ([ghi9012](https://github.com/user/repo/commit/ghi9012))
* **auth:** resolve session timeout issue ([jkl3456](https://github.com/user/repo/commit/jkl3456))

release-please is a GitHub Action that automates releases via pull requests:

# .github/workflows/release-please.yml
name: release-please
 
on:
  push:
    branches: [main]
 
permissions:
  contents: write
  pull-requests: write
 
jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          release-type: node

How it works:

  1. You merge commits to main with conventional commit messages
  2. release-please opens/updates a "Release PR" automatically
  3. The Release PR contains the changelog diff and version bump
  4. When you merge the Release PR, it creates a GitHub Release and Git tag

semantic-release (Fully Automated)

For projects that want zero manual intervention:

npm install --save-dev semantic-release
// .releaserc.json
{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/changelog",
    "@semantic-release/npm",
    "@semantic-release/github",
    "@semantic-release/git"
  ]
}

semantic-release runs in CI and automatically:

  • Analyzes commits
  • Determines the version
  • Generates release notes
  • Publishes to npm (if applicable)
  • Creates a GitHub Release
  • Tags the commit

6. Commitizen — Interactive Commit Helper

Commitizen provides an interactive prompt that guides you through creating properly formatted conventional commits.

Setup

# Install globally
npm install -g commitizen cz-conventional-changelog
 
# Configure (global)
echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc

Or per-project:

npm install --save-dev commitizen cz-conventional-changelog
 
# In package.json:
{
  "config": {
    "commitizen": {
      "path": "cz-conventional-changelog"
    }
  }
}

Usage

Instead of git commit, run:

npx cz
# Or if installed globally:
cz

The interactive prompt:

? Select the type of change you're committing:
  feat:     A new feature
  fix:      A bug fix
  docs:     Documentation only changes
  style:    Code style changes (formatting, semicolons)
  refactor: Code change that neither fixes a bug nor adds a feature
  perf:     A code change that improves performance
  test:     Adding missing tests or fixing existing tests
  build:    Changes that affect the build system
  ci:       Changes to CI configuration files
  chore:    Other changes that don't modify src or test
  revert:   Reverts a previous commit

? What is the scope of this change? (optional): auth
? Write a short description: add OAuth2 login support
? Provide a longer description: (optional)
? Are there any breaking changes? No
? Does this change affect any open issues? No

✔ Commit created: feat(auth): add OAuth2 login support

Commitizen automatically:

  • Lowercases the first letter of your description
  • Removes trailing periods
  • Formats the BREAKING CHANGE footer correctly
  • Adds Closes #N references

7. GitHub and GitLab Releases

Tags mark commits in Git. Releases are a hosting platform feature built on top of tags.

GitHub Releases

# Create a release using the GitHub CLI
gh release create v2.5.0 --title "v2.5.0" --notes "$(cat <<'EOF'
## What's New
 
### Features
- OAuth2 login support (#234)
- User search endpoint (#256)
 
### Bug Fixes
- Session timeout resolution (#278)
- Button alignment on mobile (#290)
 
### Breaking Changes
None
EOF
)"
 
# Create a release from an existing tag
gh release create v2.5.0 --title "v2.5.0" --generate-notes
 
# Create a draft release
gh release create v2.5.0 --draft --generate-notes
 
# Upload assets to a release
gh release upload v2.5.0 ./dist/app-v2.5.0.zip
 
# List releases
gh release list
 
# View a specific release
gh release view v2.5.0

GitHub Auto-Generated Release Notes

GitHub can generate release notes from merged PRs:

gh release create v2.5.0 --generate-notes

This creates release notes grouped by PR labels (features, bug fixes, etc.). Configure label mappings in .github/release.yml:

# .github/release.yml
changelog:
  categories:
    - title: "🚀 Features"
      labels: ["enhancement", "feature"]
    - title: "🐛 Bug Fixes"
      labels: ["bug", "fix"]
    - title: "📚 Documentation"
      labels: ["documentation"]
    - title: "🧹 Maintenance"
      labels: ["chore", "dependencies"]

GitLab Releases

# Using the GitLab API (via curl)
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
     --data name="v2.5.0" \
     --data tag_name="v2.5.0" \
     --data description="Release notes here..." \
     "https://gitlab.com/api/v4/projects/$PROJECT_ID/releases"

GitLab also supports release creation in .gitlab-ci.yml:

release:
  stage: deploy
  script:
    - echo "Creating release"
  release:
    tag_name: $CI_COMMIT_TAG
    description: './CHANGELOG.md'
  only:
    - tags

Command Reference

CommandDescription
git tag v1.0.0Create a lightweight tag
git tag -a v1.0.0 -m "message"Create an annotated tag
git tag -a v1.0.0 <sha>Tag a specific commit
git tagList all tags
git tag -l "v1.*"List tags matching a pattern
git show v1.0.0Show tag details and commit
git tag -d v1.0.0Delete a local tag
git push origin v1.0.0Push a specific tag
git push origin --tagsPush all tags
git push origin --follow-tagsPush only annotated reachable tags
git push origin --delete v1.0.0Delete a remote tag
git checkout v1.0.0Check out a tag (detached HEAD)
git checkout -b branch v1.0.0Create a branch from a tag
npx standard-versionAuto-bump version, changelog, commit, tag
npx standard-version --dry-runPreview without making changes
npx czInteractive conventional commit prompt
gh release create v1.0.0Create a GitHub release
gh release create v1.0.0 --generate-notesCreate release with auto-generated notes

Hands-On Lab

Setup

mkdir semver-lab && cd semver-lab
git init
 
# Initialize a minimal package.json for tooling
cat > package.json << 'EOF'
{
  "name": "semver-lab",
  "version": "0.0.0",
  "description": "Conventional commits and semver lab",
  "main": "index.js",
  "scripts": {}
}
EOF
 
cat > index.js << 'EOF'
function calculator(operation, a, b) {
  switch (operation) {
    case 'add': return a + b;
    case 'subtract': return a - b;
    default: throw new Error('Unknown operation');
  }
}
 
module.exports = { calculator };
EOF
 
git add .
git commit -m "chore: initial project setup"

Part 1: Writing Conventional Commits

# Feature commit
cat > index.js << 'EOF'
function calculator(operation, a, b) {
  switch (operation) {
    case 'add': return a + b;
    case 'subtract': return a - b;
    case 'multiply': return a * b;
    default: throw new Error('Unknown operation');
  }
}
 
module.exports = { calculator };
EOF
 
git add index.js
git commit -m "feat: add multiply operation to calculator"
 
# Bug fix commit
cat > index.js << 'EOF'
function calculator(operation, a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new TypeError('Arguments must be numbers');
  }
  switch (operation) {
    case 'add': return a + b;
    case 'subtract': return a - b;
    case 'multiply': return a * b;
    default: throw new Error('Unknown operation: ' + operation);
  }
}
 
module.exports = { calculator };
EOF
 
git add index.js
git commit -m "fix: validate inputs and improve error messages"
 
# Documentation commit
cat > README.md << 'EOF'
# Calculator
 
A simple calculator module supporting add, subtract, and multiply.
 
## Usage
 
```js
const { calculator } = require('./index');
calculator('add', 2, 3); // 5

EOF

git add README.md git commit -m "docs: add README with usage instructions"

View the history

git log --oneline


**Checkpoint**: You should see three commits with `feat:`, `fix:`, and `docs:` prefixes. Each commit message should be lowercase, imperative, and without a period.

### Part 2: Creating Your First Tagged Release

```bash
# Tag the current state as v1.0.0
git tag -a v1.0.0 -m "Version 1.0.0 - Initial release

Features:
- Basic calculator with add, subtract, multiply
- Input validation
- Documentation"

# Verify the tag
git tag
git show v1.0.0

# Verify it's annotated (not lightweight)
git cat-file -t v1.0.0
# Should output: tag

Checkpoint: git tag shows v1.0.0. git show v1.0.0 displays the tag message, tagger info, and the commit details.

Part 3: Building Toward v1.1.0 (Minor Release)

# Add a new feature
cat > index.js << 'EOF'
function calculator(operation, a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new TypeError('Arguments must be numbers');
  }
  switch (operation) {
    case 'add': return a + b;
    case 'subtract': return a - b;
    case 'multiply': return a * b;
    case 'divide':
      if (b === 0) throw new Error('Division by zero');
      return a / b;
    default: throw new Error('Unknown operation: ' + operation);
  }
}
 
module.exports = { calculator };
EOF
 
git add index.js
git commit -m "feat: add divide operation with zero-check"
 
# Add a small fix
echo "## License\nMIT" >> README.md
git add README.md
git commit -m "docs: add license section to README"
 
# Tag as v1.1.0 (new feature = minor bump)
git tag -a v1.1.0 -m "Version 1.1.0
 
Features:
- Division operation with zero-division protection
 
Documentation:
- Added license section"
 
# View tags
git tag -l "v1.*"

Checkpoint: git tag -l "v1.*" shows both v1.0.0 and v1.1.0.

Part 4: A Breaking Change (v2.0.0)

# Refactor to a different API shape (breaking change!)
cat > index.js << 'EOF'
class Calculator {
  add(a, b) { return this.#validate(a, b) && a + b; }
  subtract(a, b) { return this.#validate(a, b) && a - b; }
  multiply(a, b) { return this.#validate(a, b) && a * b; }
  divide(a, b) {
    this.#validate(a, b);
    if (b === 0) throw new Error('Division by zero');
    return a / b;
  }
 
  #validate(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new TypeError('Arguments must be numbers');
    }
    return true;
  }
}
 
module.exports = { Calculator };
EOF
 
git add index.js
git commit -m "feat!: refactor to class-based Calculator API
 
BREAKING CHANGE: the calculator() function has been replaced
with a Calculator class. Users must now instantiate the class:
 
Before: calculator('add', 2, 3)
After:  new Calculator().add(2, 3)"
 
# Update docs for new API
cat > README.md << 'EOF'
# Calculator
 
A calculator module with a clean class-based API.
 
## Usage
 
```js
const { Calculator } = require('./index');
const calc = new Calculator();
calc.add(2, 3);      // 5
calc.subtract(5, 3);  // 2
calc.multiply(3, 4);  // 12
calc.divide(10, 2);   // 5

License

MIT EOF

git add README.md git commit -m "docs: update README for new class-based API"

Tag as v2.0.0 (breaking change = major bump)

git tag -a v2.0.0 -m "Version 2.0.0

BREAKING CHANGES:

  • Replaced calculator() function with Calculator class
  • See README for migration guide

Documentation:

  • Updated README with new API examples"

View all tags and the range between releases

git tag git log --oneline v1.1.0..v2.0.0


**Checkpoint**: `git log --oneline v1.1.0..v2.0.0` should show the breaking change commit and the docs update.

### Part 5: Generating a Changelog Manually

```bash
# Generate a basic changelog from commit history
echo "# Changelog" > CHANGELOG.md
echo "" >> CHANGELOG.md

for tag in $(git tag --sort=-version:refname); do
    echo "## $tag" >> CHANGELOG.md
    echo "" >> CHANGELOG.md

    # Get the previous tag
    prev_tag=$(git tag --sort=-version:refname | grep -A1 "^${tag}$" | tail -1)

    if [ "$prev_tag" != "$tag" ]; then
        range="${prev_tag}..${tag}"
    else
        range="$tag"
    fi

    # List commits grouped by type
    git log --oneline "$range" 2>/dev/null | while read line; do
        echo "- $line" >> CHANGELOG.md
    done
    echo "" >> CHANGELOG.md
done

cat CHANGELOG.md

git add CHANGELOG.md
git commit -m "docs: add generated changelog"

Part 6: Using git shortlog for Release Notes

# Summarize contributions between releases
echo "=== v1.0.0 to v1.1.0 ==="
git shortlog v1.0.0..v1.1.0
 
echo ""
echo "=== v1.1.0 to v2.0.0 ==="
git shortlog v1.1.0..v2.0.0
 
echo ""
echo "=== All time ==="
git shortlog -sn

Checkpoint: Each range should show the relevant commits grouped by author.

Challenge

  1. Add three more commits to the repo: one feat, one fix, and one perf
  2. Determine what the next version should be based on semver rules
  3. Create the appropriate annotated tag
  4. Write a script that automatically determines the next version by parsing commit messages since the last tag (hint: check for feat → minor, fix → patch, BREAKING CHANGE or ! → major)
  5. If you have Node.js installed, try installing and running commitizen (npx cz) for one of your commits

Common Pitfalls

PitfallWhat HappensPrevention
Using lightweight tags for releasesNo metadata (who tagged, when, why); --follow-tags skips themAlways use git tag -a for releases
Forgetting to push tagsTags exist locally but not on the remoteUse git push --follow-tags or configure push.followTags true
Wrong commit typeA breaking change tagged as fix → incorrect patch bumpReview the commit type rules; use commitlint to enforce
Squash-merging loses commit typesAll conventional commits get squashed into one messageEnsure the squash commit message follows conventional format, or avoid squash merging
Emoji adapters breaking toolingChangelog generators and linters may not understand emoji typesStick to the standard text types unless your entire toolchain supports emojis
Starting at v1.0.0 too earlyFrequent breaking changes force rapid major version bumpsStart at 0.1.0 and move to 1.0.0 only when the API is stable
Tagging the wrong commitRelease points to a pre-release stateAlways tag after merging to main, never on a feature branch

Pro Tips

  1. push.followTags = true is a must-have configuration. Add it globally so you never forget to push tags: git config --global push.followTags true.

  2. Use --dry-run first. When running standard-version or semantic-release for the first time, use --dry-run to preview what will happen before making changes.

  3. Conventional commits improve git log. Even without tooling, commit messages like feat(auth): add login are far more readable than "updated stuff" when scanning history with git log --oneline.

  4. Enforce in CI, not just locally. Client-side hooks can be bypassed. Always validate commit messages in CI as a backstop. GitHub Actions can run commitlint on push events.

  5. The scope is your friend. Consistent scopes (auth, api, ui) make changelogs more useful and git log --grep="auth" more powerful.

  6. git describe shows your position relative to the last tag:

    git describe --tags
    # v2.0.0-3-gabc1234
    # = 3 commits after v2.0.0, currently at abc1234
  7. Don't mix versioning strategies. Pick one tool (standard-version, release-please, semantic-release) and stick with it. Mixing manual tagging with automated tools causes version conflicts.


Quiz / Self-Assessment

  1. In semver 2.4.1, what does each number represent?
Answer
  • 2 = MAJOR — incremented for breaking (backwards-incompatible) changes
  • 4 = MINOR — incremented for new features that are backwards compatible
  • 1 = PATCH — incremented for backwards-compatible bug fixes

When MINOR is bumped, PATCH resets to 0. When MAJOR is bumped, both MINOR and PATCH reset to 0.

  1. What is the format of a conventional commit message?
Answer
type(optional-scope): description

optional body

optional footer(s)

Example: feat(auth): add OAuth2 login support. The type is mandatory and must be one of the defined types (feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert). The scope is optional and describes the affected section. The description uses imperative tone, lowercase, no period.

  1. Which conventional commit types trigger version bumps, and what kind?
Answer
  • feat → bumps MINOR (new feature, backwards compatible)
  • fix → bumps PATCH (bug fix, backwards compatible)
  • Any type with ! or BREAKING CHANGE in the footer → bumps MAJOR

Other types (docs, style, refactor, test, build, ci, chore) do not trigger version bumps by default. The highest-impact commit since the last release determines the bump.

  1. What's the difference between a lightweight and annotated Git tag?
Answer

A lightweight tag is just a pointer to a commit — like a branch that doesn't move. It has no metadata. An annotated tag is a full Git object stored in the database, containing the tagger's name, email, date, a message, and optionally a GPG signature. Use git tag v1.0 for lightweight, git tag -a v1.0 -m "message" for annotated. Always use annotated tags for releases; --follow-tags only pushes annotated tags.

  1. Why aren't tags pushed with git push by default?
Answer

Tags are not pushed by default because they're considered local metadata by Git. Automatically pushing all tags could pollute the remote with personal bookmarks or test tags. You must explicitly push tags with git push origin <tagname>, git push --tags (all tags), or git push --follow-tags (annotated reachable tags only). Configure git config --global push.followTags true to auto-push annotated tags.

  1. How do you indicate a breaking change in a conventional commit?
Answer

Two ways (can be combined):

  1. Add ! after the type/scope: feat!: redesign API or feat(api)!: change response format
  2. Add BREAKING CHANGE: in the footer:
feat: redesign API

BREAKING CHANGE: the /api/v1 endpoints have been removed.
Use /api/v2 instead.

If using !, it's still recommended to include the BREAKING CHANGE: footer with a detailed description.

  1. What does standard-version do when you run it?
Answer

In one command, standard-version:

  1. Reads commits since the last tag
  2. Determines the version bump from commit types (feat → minor, fix → patch, BREAKING CHANGE → major)
  3. Updates CHANGELOG.md with formatted entries grouped by type
  4. Bumps the version in package.json
  5. Creates a commit: chore(release): X.Y.Z
  6. Creates an annotated Git tag: vX.Y.Z

You then push with git push --follow-tags to publish the release.

  1. What is the 0.x.x convention in semver?
Answer

Versions starting with 0 (e.g., 0.3.1) indicate that the software is in initial development and the API is not yet stable. Breaking changes can happen in any release without bumping the major version. This gives maintainers flexibility during early development. Publishing 1.0.0 signals that the public API is stable and semver rules apply strictly from that point forward.

  1. How does release-please differ from standard-version?
Answer

standard-version runs locally or in CI and directly modifies files, commits, and tags. release-please is a GitHub Action that works through pull requests: it automatically creates/updates a "Release PR" containing the changelog and version bump. When you merge the Release PR, it creates the GitHub Release and tag. This gives you a review step before publishing. release-please is Google's recommended replacement for standard-version (which is now in maintenance mode).

  1. You have commits fix: ..., docs: ..., feat: ..., and fix: ... since your last release at v3.2.1. What should the next version be?
Answer

The next version should be v3.3.0. The highest-impact commit is feat (MINOR bump). The fix commits would only warrant a PATCH bump, and docs doesn't trigger any bump. Since MINOR is the highest, the version goes from 3.2.13.3.0 (minor incremented, patch reset to 0). If any commit had BREAKING CHANGE or !, it would be 4.0.0 instead.