Automated Changelog Generation: From Commit History to Release Notes

Build automated changelog pipelines from git commit history using conventional commits, conventional-changelog, and semantic-release. Learn parsing, templating, and production patterns.

published: reading time: 21 min read author: Geek Workbench updated: March 31, 2026

Introduction

A changelog is the bridge between your code and your users. It answers the question every stakeholder asks: “What changed?” Yet most teams write changelogs manually — a tedious, error-prone process that falls behind within weeks. Automated changelog generation solves this by parsing your git history and producing structured release notes.

The secret sauce is conventional commits. When every commit follows a structured format, machines can categorize, filter, and format them into beautiful changelogs. Tools like conventional-changelog, auto, and semantic-release turn your commit log into a living document that updates with every release.

This post covers the architecture of automated changelog systems, parsing strategies, template customization, and production-hardened patterns. If your team ships software regularly, this is infrastructure you can’t afford to skip.

When to Use / When Not to Use

Use automated changelogs when:

  • You release software on a regular cadence (weekly, biweekly, monthly)
  • You use conventional commits or another structured commit format
  • You have external users or consumers who need to know what changed
  • You publish packages to npm, PyPI, or other registries
  • You want to reduce release overhead

Skip them when:

  • You release rarely (quarterly or less) and manual notes are manageable
  • Your commit history is chaotic with no structure to parse
  • You’re building internal tools where release notes aren’t consumed
  • Your team isn’t ready to adopt commit conventions

Core Concepts

Automated changelog generation works in three phases:

  1. Parse — Read git log and extract structured data from commit messages
  2. Categorize — Group commits by type (features, fixes, breaking changes, etc.)
  3. Render — Apply a template to produce formatted markdown

The input is your git log. The output is a CHANGELOG.md file. The engine is a parser-template pipeline.

flowchart LR
    A[Git Repository] --> B[git log]
    B --> C[Commit Parser]
    C --> D{Categorize}
    D -->|feat| E[Features]
    D -->|fix| F[Bug Fixes]
    D -->|BREAKING| G[Breaking Changes]
    D -->|other| H[Other]
    E --> I[Template Engine]
    F --> I
    G --> I
    H --> I
    I --> J[CHANGELOG.md]

Architecture and Flow Diagram

sequenceDiagram
    participant Rel as Release Trigger
    participant Git as Git Log
    participant Parser as Commit Parser
    participant Filter as Commit Filter
    participant Sort as Sorter
    participant Tmpl as Template Engine
    participant FS as CHANGELOG.md

    Rel->>Git: Get commits since last tag
    Git-->>Rel: Raw commit list
    Rel->>Parser: Parse each commit
    Parser->>Parser: Extract type, scope, subject
    Parser->>Parser: Detect breaking changes
    Parser-->>Rel: Structured commits
    Rel->>Filter: Remove chore/docs/test commits
    Filter-->>Rel: Release-worthy commits
    Rel->>Sort: Group by type, sort by scope
    Sort-->>Rel: Categorized commits
    Rel->>Tmpl: Render with template
    Tmpl->>Tmpl: Apply markdown template
    Tmpl-->>Rel: Formatted changelog
    Rel->>FS: Prepend to CHANGELOG.md

Step-by-Step Guide

1. Install conventional-changelog

npm install --save-dev conventional-changelog-cli

2. Generate Your First Changelog

npx conventional-changelog -p angular -i CHANGELOG.md -s

The -r 0 flag regenerates the entire changelog from all commits. Without it, only new commits since the last run are added.

Customize the Template

Create a custom template for your project’s branding:

// changelog-template.js
module.exports = function (options) {
  return {
    writerOpts: {
      transform: (commit, context) => {
        // Skip certain commits
        if (commit.type === "chore" || commit.type === "test") {
          return null;
        }
        // Add link to commit
        commit.shortHash = commit.hash.substring(0, 7);
        commit.link = `${context.host}/${context.owner}/${context.repository}/commit/${commit.hash}`;
        return commit;
      },
      groupBy: "type",
      commitGroupsSort: (a, b) => {
        const order = [
          "Breaking Changes",
          "Features",
          "Bug Fixes",
          "Performance",
          "Documentation",
        ];
        return order.indexOf(a.title) - order.indexOf(b.title);
      },
      commitsSort: ["scope", "subject"],
      noteGroupsSort: "title",
      notesSort: "text",
    },
  };
};

Integrate with semantic-release

For fully automated releases with changelog generation:

npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git

Create .releaserc.json:

{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    [
      "@semantic-release/changelog",
      {
        "changelogFile": "CHANGELOG.md"
      }
    ],
    [
      "@semantic-release/git",
      {
        "assets": ["CHANGELOG.md", "package.json"],
        "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
      }
    ],
    "@semantic-release/github"
  ]
}

Add to CI Pipeline

# .github/workflows/release.yml
name: Release
on:
  push:
    branches: [main]
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

CI/CD Integration Patterns

Release Trigger Strategies

Automating changelog generation requires reliable release triggers. The most common patterns:

Tag-Based Triggers Push a version tag → CI runs semantic-release → changelog auto-generated

git tag v1.2.0 && git push origin v1.2.0

Merge-to-Main Triggers Every merge to main triggers a release pipeline:

# .github/workflows/release-on-merge.yml
on:
  pull_request:
    types: [closed]
    branches: [main]
jobs:
  release:
    if: github.event.pull_request.merged == true
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: npx semantic-release

Commit Message Patterns Trigger releases based on commit messages:

on:
  push:
    branches: [main]
    paths:
      - "src/**"
jobs:
  release:
    if: "contains(github.event.head_commit.message, 'release:') || contains(github.event.head_commit.message, 'feat:') || contains(github.event.head_commit.message, 'fix:')"

Commitlint Integration

Enforce conventional commits at the PR level before merging:

npm install --save-dev @commitlint/config-conventional @commitlint/cli
// commitlint.config.js
module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "type-enum": [
      2,
      "always",
      [
        "feat",
        "fix",
        "docs",
        "style",
        "refactor",
        "perf",
        "test",
        "build",
        "ci",
        "chore",
        "revert",
      ],
    ],
    "type-case": [2, "always", "lower-case"],
    "type-empty": [2, "never"],
    "subject-empty": [2, "never"],
    "subject-full-stop": [2, "never", "."],
  },
};
# .github/workflows/commitlint.yml
name: Lint Commits
on:
  pull_request:
    types: [opened, synchronize, reopened]
jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: wagoid/commitlint-github-action@v5

Changelog Validation in CI

Add a validation step that fails if the changelog would be empty or suspiciously small:

// scripts/validate-changelog.js
import conventionalChangelog from "conventional-changelog";
import { readFileSync } from "fs";

const context = { version: "1.0.0", title: "Changelog" };
const options = { preset: "angular" };

let entryCount = 0;
let hasBreakingChanges = false;

conventionalChangelog(options, context)
  .on("data", (chunk) => {
    const text = chunk.toString();
    if (text.includes("### Features") || text.includes("### Bug Fixes")) {
      entryCount++;
    }
    if (text.includes("BREAKING")) {
      hasBreakingChanges = true;
    }
  })
  .on("end", () => {
    if (entryCount === 0) {
      console.error("Validation failed: Changelog has zero entries");
      process.exit(1);
    }
    if (hasBreakingChanges) {
      console.log("Breaking changes detected — manual review recommended");
    }
    console.log(`Changelog validation passed with ${entryCount} entries`);
  });

Run this in CI before publishing:

node scripts/validate-changelog.js && npx semantic-release

Pre-commit Hooks for Commit Enforcement

Set up local commit message validation before push:

npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'

Or validate on commit-msg hook:

#!/bin/sh
npx commitlint --edit ${1}

This catches non-conventional commits before they enter your history.

Production Failure Scenarios

ScenarioImpactMitigation
Parser fails on non-conventional commitChangelog generation stopsUse --skip-unparsed flag or pre-filter commits
Duplicate entries in changelogConfusing release notesUse -s flag for in-place deduplication
Missing git tagsCan’t determine commit rangeEnsure tags are pushed; use git fetch --tags in CI
Template rendering errorBroken changelog formatTest template locally before CI deployment
Large repository historySlow changelog generationUse shallow clone with sufficient depth; cache parsed results
Merge commits polluting logDuplicate feature entriesConfigure parser to skip merge commits or use --first-parent

Trade-off Analysis

AspectAutomated ChangelogManual Changelog
AccuracyHigh (directly from commits)Variable (human error)
EffortZero after setupOngoing per release
CustomizationTemplate-boundUnlimited
Commit discipline requiredYes (conventional commits)No
Breaking change detectionAutomaticManual review
Historical migrationCan regenerate from scratchMust write manually

Implementation Snippets

Generate changelog for specific version range:

# From last tag to HEAD
npx conventional-changelog -p angular -i CHANGELOG.md -s

# Specific range
npx conventional-changelog -p angular -i CHANGELOG.md -s -r 1

# All history
npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0

Custom parser configuration:

// conventional-changelog.config.js
module.exports = {
  preset: "angular",
  tagPrefix: "v",
  skip: {
    bump: false,
    changelog: false,
    commit: false,
    tag: false,
  },
  gitRawCommitsOpts: {
    merges: null, // Exclude merge commits
  },
};

Programmatic API usage:

import conventionalChangelog from "conventional-changelog";
import { createWriteStream } from "fs";

const stream = conventionalChangelog({
  preset: "angular",
  tagPrefix: "v",
});

stream.pipe(createWriteStream("CHANGELOG.md", { flags: "a" }));
stream.on("end", () => console.log("Changelog updated"));

Filter commits by scope:

# Only include commits with 'api' scope
npx conventional-changelog -p angular -i CHANGELOG.md -s \
  --config ./changelog-config.js
// changelog-config.js
module.exports = {
  writerOpts: {
    transform: (commit) => {
      if (commit.scope === "api") {
        return commit;
      }
      return false; // Filter out non-api commits
    },
  },
};

Observability Checklist

  • Logs: Log number of commits processed per release
  • Metrics: Track changelog generation time and file size growth
  • Alerts: Alert when changelog generation fails in CI
  • Dashboards: Monitor release frequency and commit type distribution
  • Traces: Trace commit → changelog entry → release note for audit

Security & Compliance Considerations

  • Changelogs are public documents — never include internal ticket numbers that expose security vulnerabilities
  • For regulated software, ensure changelogs include compliance-relevant changes (security patches, data handling updates)
  • Link to CVE IDs when fixing security issues: fix(auth): patch XSS vulnerability (CVE-2024-1234)
  • Consider generating separate internal and external changelogs for sensitive projects

Common Pitfalls / Anti-Patterns

Anti-PatternWhy It’s BadFix
Including every commitNoise overwhelms signalFilter out chore, test, style commits
No deduplicationSame change listed multiple timesUse -s flag for in-place editing
Ignoring merge commitsDuplicate entries in changelogConfigure parser to skip or squash merges
Manual edits to CHANGELOG.mdGets overwritten on next runAdd CHANGELOG.md to .gitignore for auto-generation or use semantic-release git plugin
Not testing templatesBroken formatting in productionRender template locally before CI
Forgetting tag prefixCan’t find version tagsSet tagPrefix: 'v' in config

Quick Recap Checklist

  • Install conventional-changelog-cli
  • Configure preset matching your commit convention
  • Add npm script for changelog generation
  • Set up custom template if needed
  • Integrate with semantic-release for full automation
  • Add CI workflow for automated releases
  • Configure commit filtering to reduce noise
  • Test changelog generation locally before deploying

Extended Architecture Diagram

flowchart LR
    subgraph "Input"
        A[Git Repository] --> B[git log]
        B --> C[Raw Commits]
    end

    subgraph "Parsing"
        C --> D[Commit Parser]
        D --> E{Conventional Format?}
        E -->|Yes| F[Extract type, scope, subject]
        E -->|No| G[Skip or flag]
        F --> H[Detect BREAKING CHANGE]
    end

    subgraph "Categorization"
        H --> I{Group by Type}
        I -->|feat| J[Features]
        I -->|fix| K[Bug Fixes]
        I -->|perf| L[Performance]
        I -->|BREAKING| M[Breaking Changes]
        I -->|docs/chore/test| N[Excluded]
    end

    subgraph "Rendering"
        J --> O[Template Engine]
        K --> O
        L --> O
        M --> O
        O --> P[Apply Markdown Template]
        P --> Q[CHANGELOG.md]
    end

Extended Production Failure Scenario

Missing Conventional Commits Causing Incomplete Changelog

A developer squash-merges a PR with 15 commits into a single commit message that doesn’t follow conventional format: “Merge pull request #42 from feature/auth”. The changelog generator processes this commit, finds no recognized type prefix, and skips it entirely. The release ships with new authentication features but the changelog shows zero features — only unrelated bug fixes from other commits. Users have no idea what changed.

Mitigation: Enforce conventional commits at the PR level with commitlint on the CI pipeline. Configure squash merge to preserve the PR title as the commit message, and require PR titles to follow the conventional format. Add a changelog validation step that fails if a release contains zero feature entries but has code changes.

Extended Trade-offs

Aspectconventional-changeloggit-cliffManual
AutomationFull — runs as CLI or libraryFull — single binary, config-drivenNone
CustomizationTemplate-based, JavaScript configTOML config, custom templates, regexUnlimited
Maintenancenpm dependency, regular updatesStandalone binary, minimal depsOngoing human effort
EcosystemNode.js native, semantic-release integrationLanguage-agnostic, works with any repoN/A
Learning curveMedium — JS config, template syntaxLow — TOML config is straightforwardN/A
Monorepo supportVia lerna-changelog or custom configBuilt-in with git-cliff groupsManual per-package

Implementation Snippet: git-cliff Configuration

# cliff.toml
[changelog]
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
body = """
{% if version %}\
    ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
    ## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
    ### {{ group | upper_first }}
    {% for commit in commits %}
        - {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }}\
    {% endfor %}
{% endfor %}\n
"""
footer = """
<!-- generated by git-cliff -->
"""
trim = true

[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
    { message = "^feat", group = "Features" },
    { message = "^fix", group = "Bug Fixes" },
    { message = "^perf", group = "Performance" },
    { message = "^docs", group = "Documentation" },
    { message = "^BREAKING", group = "Breaking Changes" },
    { message = "^chore\\(release\\)", skip = true },
    { message = "^chore", skip = true },
    { message = "^ci", skip = true },
    { message = "^test", skip = true },
]
protect_breaking_changes = true
filter_commits = true
tag_pattern = "v[0-9].*"
sort_commits = "newest"
# Generate changelog with git-cliff
git-cliff --output CHANGELOG.md

# Generate for specific tag range
git-cliff v1.0.0..v1.2.0 --output CHANGELOG.md

# Unreleased changes only
git-cliff --unreleased --output CHANGELOG.md

Interview Questions

1. How does conventional-changelog determine which commits to include?

It reads git log between the current HEAD and the last git tag. The tag is determined by parsing version numbers from existing tags. Commits are then filtered by type (features, fixes, breaking changes) and formatted using the selected preset template.

2. What's the difference between conventional-changelog and semantic-release?

conventional-changelog only generates the changelog file. semantic-release is a full release automation tool that analyzes commits, determines the next version, generates the changelog, creates git tags, and publishes to registries. semantic-release uses conventional-changelog under the hood.

3. How do you handle a changelog that needs both automated and manual entries?

Use the prepend mode of conventional-changelog. Generate the automated section, then manually add entries above it. Alternatively, use semantic-release with a custom template that includes a manual section, or maintain a separate RELEASE_NOTES.md for human-written content alongside the auto-generated changelog.

4. Why might your automated changelog miss commits after a squash merge?

Squash merges create a single new commit that doesn't preserve the original commit messages' structure. The squashed commit message may not follow conventional commit format, causing the parser to skip it. Configure your repository to use rebase merges or ensure the squash message follows the convention.

5. How do you generate a changelog for a monorepo with multiple packages?

Use lerna-changelog or changesets which are designed for monorepos. They track changes per package and generate individual changelogs. Alternatively, configure conventional-changelog with custom filters to group commits by package scope.

6. How can you configure changelog generation to include only certain commit types?

In most changelog tools like conventional-changelog, you use configuration to filter by commit type. For example, you can include only feat, fix, and BREAKING CHANGE commits while excluding chore, docs, and test. This is done through the preset configuration or by defining custom commitGroups in the changelog config. The trade-off is that excluding types reduces noise but may miss important context about refactors or dependency updates.

7. What is the difference between `--first-parent` and including merge commits in changelog generation?

--first-parent follows only the main branch line when traversing history. Merge commits are included but only their message is used. Without it, all commits from merged branches appear as individual entries, causing duplicate and confusing entries. For teams using merge strategies, --first-parent produces cleaner changelogs by only showing commits that went directly into the main branch.

8. How would you handle changelog generation for a project that switched from non-conventional to conventional commits mid-history?

Use the -r flag to regenerate from a specific point. Run conventional-changelog from the date/tag when the switch happened using --from and --to options. Older commits can either be: manually added in a "Legacy History" section, excluded entirely, or processed with a custom parser that attempts to extract meaningful information from non-standard messages. Semantic-release supports a preset option where you can configure custom parsing rules for legacy formats.

9. What are the security implications of including commit hashes in public changelogs?

Including full commit hashes exposes your repository's commit graph publicly, which can aid attackers in understanding your codebase's evolution. However, shortened hashes (7 characters) and links to commit pages (e.g., GitHub) are standard practice and acceptable. Never include internal ticket numbers that reference security vulnerabilities. For regulated industries, ensure no sensitive identifiers leak through commit metadata. Consider generating separate internal changelogs with full detail and external ones with sanitized information.

10. How do you configure changelog generation to skip certain commit types while still detecting breaking changes?

Configure the skip option in your preset or writer options to exclude types like chore, docs, test, refactor, and style. Breaking changes are detected via the BREAKING CHANGE: footer or ! notation — these are checked regardless of commit type. In conventional-changelog, set skip in writerOpts to return null for excluded types, but the breaking change detection runs on all commits before filtering.

11. What happens to your changelog if you accidentally push a revert commit?

A revert commit formatted as revert: message will appear in the changelog as a revert entry, which may not be meaningful to end users. To handle this properly: add custom logic to detect reverts (check for "This reverts commit" in body), optionally filter them out via writerOpts transform, or add a revert: type that gets grouped separately. Semantic-release has no special handling for reverts — they appear as regular commits unless you customize the parser.

12. How would you implement a changelog that includes links to related GitHub issues or PRs?

Use the context object in the template to provide repository metadata, then construct links in your transform function:

  • Pass owner, repository, host (e.g., "https://github.com") to the changelog preset
  • In the transform, add commit.issue and commit.PR links
  • Update the template to render issue/PR links in the output

For GitHub specifically, use context.host + '/' + context.owner + '/' + context.repository + '/issues/' + commit.issue. Semantic-release's GitHub plugin automatically adds issue references when configured with the GitHub repository.

13. Why might conventional-changelog generate an empty changelog even though commits were made?

Common causes: (1) No git tags exist yet — it compares HEAD to the oldest tag; if no tags, it produces nothing. (2) Commits don't follow the configured preset (e.g., using "feat:" with Angular preset when "Feature:" is required). (3) All commits were filtered out (chore, docs, test types). (4) Using shallow clone (fetch-depth: 1) in CI — needs full history to find tags. (5) The -r flag was omitted, so it only adds commits since last run, and there were no new commits matching the criteria.

14. How do you maintain changelog quality when team members forget to use conventional commits?

Layer multiple defenses: (1) Pre-commit hooks via husky that run commitlint on every commit. (2) PR title enforcement via GitHub branch rules — squash merge uses PR title as commit message. (3) CI pipeline check that fails if non-conventional commits are detected in a merge. (4) CODEOWNERS file requiring review for merge. (5) Documentation in CONTRIBUTING.md with examples. (6) For legacy commits, use a "chore(legacy):" prefix for manual entries and configure the parser to include these in the changelog.

15. What is the purpose of the `-s` flag in conventional-changelog and when would you omit it?

The -s (or --same-file) flag appends new changelog entries to the existing file instead of overwriting it. Without -s, the generated changelog is written to stdout or a new file. You'd omit -s when: generating a standalone changelog for distribution, creating a temporary changelog for review, or outputting to different formats. You'd use -s for the typical workflow where you prepend to an existing CHANGELOG.md on each release.

16. How does keepachangelog.com format differ from conventional-changelog output?

Keep a Changelog format emphasizes human readability with sections like Added, Changed, Deprecated, Removed, Fixed, Security. It does not automatically parse git history — it's a manual template. Conventional-changelog automates generation but uses a machine-oriented format grouped by commit type (Features, Bug Fixes, etc.). Keep a Changelog is better for end-user facing release notes; conventional-changelog is better for automated pipeline integration. Many teams use both: auto-generate with conventional-changelog, then transform into Keep a Changelog format for public releases.

17. How would you migrate an existing project's manually-written changelog to an automated one?

Steps: (1) Ensure all future commits follow conventional format. (2) Add a historical cutoff date — generate automated changelog from that date forward using --from flag. (3) Create a "Legacy Changelog" section for pre-conventional commits manually. (4) Set up commitlint to prevent future drift. (5) Consider using -r 0 once to regenerate everything if commit history is clean enough. For messy histories, maintain a manual "Legacy History" section above the automated output.

18. What is the performance impact of running changelog generation on large repositories?

For repositories with 100k+ commits, changelog generation can take 30-60 seconds due to git log traversal and string parsing. Mitigations: use shallow clone (fetch-depth: 100 or enough for your release cadence), cache parsed results between runs, use --first-parent to reduce commit volume, skip merge commits with gitRawCommitsOpts.merges = null. git-cliff is notably faster than Node.js-based tools for large repos since it's a compiled binary.

19. How do you handle internationalization concerns when generating changelogs for a global audience?

Changelogs for international audiences should: use simple English avoiding idioms, keep technical terms in English with explanations for non-technical readers, separate language-specific content from formatting (use template i18n), consider generating locale-specific changelogs via separate templates, use clear date formats (YYYY-MM-DD) that are universally understood. Automated changelogs preserve the commit subject language — for global consumption, consider a translation step or separate localized changelog files.

20. What metrics should teams track to measure changelog quality over time?

Key metrics: Commit convention adherence rate (what % of commits follow conventional format), changelog coverage (% of actual changes reflected in changelog), user-facing clarity score (surveys or comments from users), time-to-release (does automation reduce release time?), missing features rate (how often are significant changes omitted), breaking change detection accuracy (are breaking changes properly flagged?). Track these weekly in CI dashboards to catch drift early.

Further Reading

Conclusion

A good changelog tells the story of a release — what changed, who contributed, and what to watch for. Automated changelog generation from conventional commits eliminates the friction of manual release notes and ensures nothing gets lost between releases.

Category

Related Posts

Commit Message Conventions: Conventional Commits, Angular Style, and Semantic Commits

Master commit message conventions including Conventional Commits, Angular style, and semantic commits. Learn automated changelog generation, linting enforcement, and team-wide standards.

#git #version-control #conventional-commits

Automated Releases and Tagging

Automate Git releases with tags, release notes, GitHub Releases, and CI/CD integration for consistent, repeatable software delivery.

#git #version-control #automation

Automated Release Pipeline: From Git Commit to Production Deployment

Build a complete automated release pipeline with Git, CI/CD, semantic versioning, changelog generation, and zero-touch deployment. Hands-on tutorial for production-ready releases.

#git #version-control #ci-cd