Changelog Writing skill for Codex

Entries users can act on, not commit subjects. Codex writes changelog entries from the reader's side, naming the command, endpoint or option that changed and what to do about it, and leaves out refactors and CI noise. It follows Keep a Changelog headings, ISO dates and compare links, picks curated entries, changesets, towncrier or git-cliff drafts to fit the team, and ships a standard-library linter that can gate pull requests.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- changelog-writing

Installs to ~/.agents/skills/changelog-writing/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $changelog-writing, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and maintains CHANGELOG.md in the Keep a Changelog format - an Unreleased section, Added, Changed, Deprecated, Removed, Fixed and Security groups, ISO dates, compare links - with entries written for the people upgrading rather than copied commit subjects, and chooses between curated entries, changesets, towncrier fragments and git-cliff generation. Includes a linter script. Use when asked to update or create a changelog, add an entry for a PR, turn commits into a changelog for a release, set up changesets or towncrier, or clean up a messy CHANGELOG.

Use it when

  • A pull request makes a user-visible change and needs an entry
  • A release is being prepared and the Unreleased section must be finalised
  • Concurrent PRs keep conflicting on CHANGELOG.md
  • An existing changelog is inconsistent or just a commit dump

Use something else when

How to check the result

Run scripts/check_changelog.py on the file and confirm every merged user-facing PR since the last tag maps to exactly one entry.

The mistake it prevents

Pasting raw commit subjects into the changelog, so users read about refactors while the one breaking rename is buried.

~/.agents/skills/changelog-writing/SKILL.md

--- name: changelog-writing description: Writes and maintains CHANGELOG.md in the Keep a Changelog format - an Unreleased section, Added, Changed, Deprecated, Removed, Fixed and Security… license: MIT ---

A good changelog lets a user decide in two minutes whether an upgrade affects them and what they must do. Every entry describes an effect a user can observe, breaking changes and required actions are impossible to miss, and the file is in a predictable format that tools and humans can both parse.

The format (Keep a Changelog 1.1.0)

# Changelog

All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- `export --format parquet` writes Parquet files directly (#212).

## [2.0.0] - 2026-03-01

### Removed
- **Breaking:** `createRetryingClient()` is gone; pass `retries` to `createClient()`
  instead. See the [2.0 migration guide](docs/migrate-2.md).

### Fixed
- Requests that receive HTTP 429 now wait for the `Retry-After` interval instead of
  retrying immediately (#201).

[Unreleased]: https://github.com/acme/tool/compare/v2.0.0...HEAD
[2.0.0]: https://github.com/acme/tool/compare/v1.9.3...v2.0.0

Rules:

  • Newest version first; ## [Unreleased] always at the top.
  • Version headings: ## [X.Y.Z] - YYYY-MM-DD (ISO 8601 date, the release date).
  • Only these groups, in this order: Added, Changed, Deprecated, Removed, Fixed, Security. Omit empty groups.
  • Link references at the bottom for every version (compare URLs).
  • Yanked releases keep their section with [YANKED] after the date.

Writing entries

Bad (commit-speak)Good (user effect)
fix: handle nil in parseOpts--config with an empty file no longer crashes; defaults are used instead.
refactor auth middleware(omit: no user-visible change)
bump lodash to 4.17.21Security: updated lodash to 4.17.21 to address CVE-2021-23337 (prototype pollution).
feat(api): add cursorGET /v1/orders accepts cursor for stable pagination; page still works.
BREAKING: rename env varBreaking: APP_DB_URL is now DATABASE_URL. Rename it in your environment before upgrading.

Guidelines:

  • Lead with the thing the user touches (command, endpoint, option, screen), then the effect.
  • Past or present tense consistently; full sentences; no internal ticket jargon.
  • Breaking entries start with **Breaking:**, say what to do, and link migration docs. They go in Changed or Removed, and also in the release notes summary.
  • Reference the PR or issue ((#201)) for readers who want detail; credit external contributors (Thanks @handle).
  • Leave out refactors, CI changes, test-only changes and dependency bumps with no user effect. A changelog is not a commit log.
  • One entry per user-visible change, even if it took ten commits; several entries if one PR changed several visible things.

Curated vs generated

SituationApproach
Small project, few contributorsCurated: edit [Unreleased] in every user-facing PR
Many concurrent PRs, conflicts on CHANGELOG.mdFragments: changesets (JS) or towncrier (Python), one file per PR, merged at release
Strict Conventional Commits already enforcedGenerate a draft with git-cliff or release-please, then edit wording by hand
Monorepo with independently versioned packageschangesets (per-package bumps and changelogs)

Generated output is a draft. Commit subjects describe what changed in code; entries must describe what changed for users. Always edit before release.

Tools

Installing these needs network; use the version pinned in the repo when present.

# changesets (npm workspaces/pnpm)
npx changeset                 # interactive: pick packages, bump type, write summary -> .changeset/*.md
npx changeset version         # consumes fragments, bumps versions, writes CHANGELOG.md
# towncrier (Python): fragments named <issue>.<type>.md in the configured directory
towncrier create 212.feature.md --content "Export to Parquet with --format parquet."
towncrier build --version 2.1.0 --draft     # preview without deleting fragments
# git-cliff: draft from Conventional Commits
git cliff --unreleased --tag v2.1.0 --prepend CHANGELOG.md

Never let changeset version, towncrier build or a release bot rewrite an existing released section. Released entries are history; correct them only for factual errors, and say so.

Workflow

Adding an entry for a PR

- [ ] Read AGENTS.md / CONTRIBUTING for the project's changelog rule (curated, fragments, generated)
- [ ] Decide if the change is user-visible; if not, say "no changelog entry needed" and why
- [ ] Pick the group (Added / Changed / Deprecated / Removed / Fixed / Security)
- [ ] Write the entry from the user's side; mark breaking changes
- [ ] Run the linter

Preparing a release section

git log --oneline $(git describe --tags --abbrev=0)..HEAD   # everything since the last tag
gh pr list --state merged --base main --search "merged:>=2026-02-01" --limit 100   # needs network
  1. Cross-check [Unreleased] against merged PRs; add missing user-visible changes.
  2. Check that each breaking change matches the version bump (see semantic-versioning).
  3. Rename [Unreleased] to [X.Y.Z] - <today>; add a fresh empty [Unreleased] above it.
  4. Update the link references: [Unreleased] compares the new tag to HEAD; the new version compares the previous tag to the new tag.
  5. Run the linter; commit in the release PR.

Lint the file

python3 scripts/check_changelog.py CHANGELOG.md
python3 scripts/check_changelog.py CHANGELOG.md --require-unreleased-entries   # in PR CI

The script checks heading format, ISO dates, newest-first SemVer order, allowed group names, empty groups, duplicate versions and missing link references. It exits 1 on any problem, so it can gate CI.

Gotchas

  • Dates in US or European format (03/01/2026) are ambiguous. ISO only.
  • Moving entries from [Unreleased] by hand at release time, under pressure, is where entries get lost. Rename the heading instead of moving lines.
  • Security fixes without a CVE or advisory link leave users unable to judge urgency; link the advisory once it is public, and do not describe an unpatched exploit in detail.
  • A changelog entry and a marketing announcement are different documents. Keep the changelog factual; announcements belong in release notes or a blog post.
  • Fragments with the wrong type (feature filed as bugfix) end up in the wrong group; review the rendered draft, not just the fragment list.

Output

When asked to update the changelog, return the diff to CHANGELOG.md plus a short note:

**Changelog updated:** [Unreleased] +2 entries (Added: Parquet export; Fixed: 429 handling)
**Skipped (no user effect):** refactor of auth middleware, CI cache change
**Linter:** OK

Verification

  • check_changelog.py passes.
  • Every merged user-facing PR since the last tag maps to an entry, and every entry maps to a PR or commit.
  • Each breaking entry tells the reader what to change, and the version bump is MAJOR (or 0.x MINOR).

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Changelog Writing"
  short_description: "Entries users can act on, not commit subjects"
  default_prompt: "$changelog-writing Add a changelog entry for this PR"
scripts/check_changelog.py130 lines, 5.3 KB
#!/usr/bin/env python3
"""Lint a CHANGELOG.md written in Keep a Changelog style. Standard library only.

Checks: an [Unreleased] section, version headings as "## [X.Y.Z] - YYYY-MM-DD",
versions in descending order, valid change-type subsections, no empty subsections,
no duplicate versions, and link references for every bracketed version.

Usage: python3 scripts/check_changelog.py [CHANGELOG.md] [--require-unreleased-entries]
Exit code 0 when clean, 1 when problems are found, 2 on usage errors.
"""
from __future__ import annotations

import argparse
import re
import sys
from datetime import date
from pathlib import Path

TYPES = {"Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"}
VERSION_RE = re.compile(
    r"^## \[(?P<ver>[^\]]+)\](?: - (?P<date>\d{4}-\d{2}-\d{2}))?(?P<rest>.*)$")
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$")
LINKREF_RE = re.compile(r"^\[(?P<ver>[^\]]+)\]:\s+\S+")


def semver_key(v: str):
    m = SEMVER_RE.match(v)
    if not m:
        return None
    major, minor, patch, pre = m.groups()
    # A release sorts after any of its pre-releases.
    pre_key = (1,) if pre is None else (0, *[(0, int(p), "") if p.isdigit() else (1, 0, p)
                                             for p in pre.split(".")])
    return (int(major), int(minor), int(patch), pre_key)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("path", nargs="?", default="CHANGELOG.md")
    ap.add_argument("--require-unreleased-entries", action="store_true",
                    help="fail if [Unreleased] has no entries (use on PRs)")
    args = ap.parse_args()
    path = Path(args.path)
    if not path.is_file():
        print(f"error: {path} not found", file=sys.stderr)
        return 2

    lines = path.read_text(encoding="utf-8").splitlines()
    problems: list[str] = []
    versions: list[tuple[int, str]] = []
    linkrefs: set[str] = set()
    current: str | None = None
    current_type: tuple[int, str] | None = None
    entries_in_type = 0
    unreleased_entries = 0

    def close_type() -> None:
        if current_type and entries_in_type == 0:
            problems.append(f"line {current_type[0]}: '### {current_type[1]}' under [{current}] has no entries")

    for n, line in enumerate(lines, start=1):
        if m := LINKREF_RE.match(line):
            linkrefs.add(m["ver"])
            continue
        if line.startswith("## "):
            close_type()
            current_type, entries_in_type = None, 0
            m = VERSION_RE.match(line)
            if not m:
                problems.append(f"line {n}: version heading should look like '## [1.2.0] - 2026-03-01': {line!r}")
                current = None
                continue
            current = m["ver"]
            if m["rest"].strip() and "[YANKED]" not in m["rest"]:
                problems.append(f"line {n}: unexpected text after heading: {m['rest'].strip()!r}")
            if current == "Unreleased":
                if versions:
                    problems.append(f"line {n}: [Unreleased] must be the first version section")
            else:
                if not m["date"]:
                    problems.append(f"line {n}: [{current}] is missing ' - YYYY-MM-DD'")
                else:
                    try:
                        date.fromisoformat(m["date"])
                    except ValueError:
                        problems.append(f"line {n}: invalid date {m['date']}")
                if semver_key(current) is None:
                    problems.append(f"line {n}: [{current}] is not a SemVer version")
            versions.append((n, current))
        elif line.startswith("### "):
            close_type()
            name = line[4:].strip()
            if name not in TYPES:
                problems.append(f"line {n}: '### {name}' is not one of {', '.join(sorted(TYPES))}")
            current_type, entries_in_type = (n, name), 0
        elif line.lstrip().startswith(("- ", "* ")) and current_type:
            entries_in_type += 1
            if current == "Unreleased":
                unreleased_entries += 1
    close_type()

    names = [v for _, v in versions]
    if "Unreleased" not in names:
        problems.append("missing '## [Unreleased]' section at the top")
    seen: set[str] = set()
    for n, v in versions:
        if v in seen:
            problems.append(f"line {n}: duplicate version [{v}]")
        seen.add(v)
    released = [(n, v, semver_key(v)) for n, v in versions if v != "Unreleased" and semver_key(v)]
    for (n1, v1, k1), (n2, v2, k2) in zip(released, released[1:]):
        if k2 >= k1:
            problems.append(f"line {n2}: [{v2}] is not older than [{v1}] above it; list versions newest first")
    for n, v in versions:
        if v not in linkrefs:
            problems.append(f"line {n}: no link reference '[{v}]: <compare url>' at the bottom")
    if args.require_unreleased_entries and unreleased_entries == 0:
        problems.append("[Unreleased] has no entries; add one for this change")

    if problems:
        print(f"{path}: {len(problems)} problem(s)")
        for p in problems:
            print(f"  - {p}")
        return 1
    print(f"{path}: OK ({len(released)} released versions, {unreleased_entries} unreleased entries)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem