From the structural difference between two specifications to a readable changelog draft
A hand maintained changelog falls behind almost inevitably once several developers work on the same API in parallel: one change gets forgotten, another documented too late, and eventually nobody trusts the document anymore. The more reliable path does not start with a human trying to remember every change, it starts with a structural comparison of two OpenAPI versions, from which a changelog draft can be derived mechanically before an editor turns it into readable sentences.
Table of Contents
- 1. Why hand maintained changelogs fail in practice
- 2. What a structural OpenAPI diff actually gives you
- 3. From structured diff to readable changelog draft
- 4. Categorizing by severity instead of chronological order
- 5. Where pure automation hits its limits
- 6. Differentiating changelog entries for internal and external readers
- 7. The path from automation to editorial follow up
- 8. Integrating this into the existing release process
- 9. Format and distribution of the finished changelog
- 10. Summary
- 11. FAQ
1. Why hand maintained changelogs fail in practice
In theory, every developer merging an API change is also supposed to add a changelog entry. In practice, that second step regularly falls by the wayside, because it happens outside the actual code, no tests catch it, and a missing entry rarely stands out during code review as long as the functionality itself works correctly. After a few months, the changelog ends up containing a mix of detailed entries for minor changes and missing entries for genuine breaking changes, which makes the document practically worthless for consumer teams.
The problem gets worse once several teams work on different parts of the same API in parallel, because then there often is not even a single responsible editor who sees every change. A structural diff between two OpenAPI documents does not have this problem: it sees every change that actually landed in the specification, regardless of whether the original developer remembered to document it. That shifts the task from 'remember to document everything' to 'translate already structured data into readable text', which is considerably more reliable to automate.
2. What a structural OpenAPI diff actually gives you
A diff tool like oasdiff does not compare two OpenAPI documents line by line like a classic text diff, it compares them semantically along their structure: new paths, removed paths, new or removed HTTP methods per path, new or removed parameters, changed parameter types, new or removed fields in request and response schemas, changed required status, new or removed enum values, and changes to security requirements like a newly required scope. Each of these categories maps cleanly onto a change type, additive, removing, or modifying, and that categorization is precisely the foundation for an automatically generated changelog entry.
The output of oasdiff changelog already provides a structured list of entries in JSON or YAML, each with a distinct change type such as endpoint-added, request-property-removed, or response-required-property-added, along with the affected path and HTTP method. That machine readable structure is the crucial difference from a hand written changelog: it can be fed into a template, grouped by category, and automatically sorted with breaking changes at the top of the document, without a human ever having to look at the raw data.
3. From structured diff to readable changelog draft
The move from raw diff to readable text happens through a template engine that maps each change type onto a preformulated sentence pattern. An entry of type request-property-added named 'discountCode' automatically becomes 'The optional parameter discountCode was added to the request of endpoint POST /api/v2/orders', while an entry of type response-property-removed becomes 'The field legacyId was removed from the response of GET /api/v2/orders/{id} (breaking change)'. These sentence templates do not need to be perfectly phrased, since they are explicitly meant as a draft, not as final published text.
In a Symfony project this step works well as a dedicated console command that reads the JSON output of oasdiff changelog, renders it into Markdown via Twig templates, and stores the result as a draft file in the repository. The snippet below shows such a command, translating the raw diff entries into categorized Markdown sections, with breaking changes deliberately listed first so an editor cannot miss them while reading.
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\ChangelogEntryFormatter;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Reads the JSON output of "oasdiff changelog" and renders it into a
* categorized Markdown draft for editorial follow up.
*/
#[AsCommand(name: 'app:api:changelog-draft', description: 'Generates a changelog draft from an OpenAPI diff')]
final class GenerateChangelogDraftCommand extends Command
{
public function __construct(
private readonly ChangelogEntryFormatter $formatter,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('diffJsonPath', InputArgument::REQUIRED, 'Path to the oasdiff JSON file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var string $path */
$path = $input->getArgument('diffJsonPath');
$rawDiff = json_decode(
file_get_contents($path) ?: '[]',
true,
512,
JSON_THROW_ON_ERROR,
);
$breaking = [];
$additions = [];
$other = [];
foreach ($rawDiff as $entry) {
$line = $this->formatter->format($entry);
match (true) {
$entry['level'] === 'error' => $breaking[] = $line,
str_ends_with((string) $entry['id'], '-added') => $additions[] = $line,
default => $other[] = $line,
};
}
$output->writeln('## Breaking Changes');
$output->writeln($breaking ?: ['No breaking changes in this version.']);
$output->writeln('## New Features');
$output->writeln($additions ?: ['No additive changes in this version.']);
$output->writeln('## Other Changes');
$output->writeln($other ?: ['No further changes.']);
return Command::SUCCESS;
}
}
4. Categorizing by severity instead of chronological order
An automatically generated changelog only becomes genuinely useful once it stops listing every change chronologically and instead sorts them by relevance for the reader. In practice a three way split works well: breaking changes that require active adjustment on the consumer side at the top, followed by new features that are additive and harmless but might still matter to some teams, and finally internal or cosmetic changes like corrected description text, which are irrelevant to most readers but should still be documented for completeness.
This categorization can be derived directly from the severity levels oasdiff already assigns, splitting changes into error (breaking), warning (potentially relevant), and info (purely informational). A good changelog template uses these levels to automatically generate headings and a sensible order, so a consumer team can tell at a glance whether a reaction is needed without reading through the entire list. This structure alone accounts for a large part of a good changelog's value, before a human has rephrased a single sentence.
5. Where pure automation hits its limits
An automatically generated entry reliably describes WHAT changed structurally, but rarely WHY. A consumer developer often wants to know not just that a field was removed, but what to replace it with and whether a migration is required. That context does not live in the OpenAPI specification itself, only in the head of the developer who made the change, and therefore has to be added manually. Likewise, an automated tool cannot judge whether a change is irrelevant to most consumers because nobody uses it anyway, or critical because one large internal customer relies on exactly that field.
Another blind spot involves semantic changes that leave no structural trace in the schema: if only the meaning of a field changes, say a price field suddenly returns a value including VAT instead of excluding it, the field's type stays identical and a structural diff detects no change at all, even though it represents a serious breaking change. Such cases can only be caught through a deliberate, manual addition to the changelog, which is why pure automation should never be treated as the sole source of truth, but always as a starting point for editorial review.
6. Differentiating changelog entries for internal and external readers
Not every consumer of a changelog has the same information needs. An internal frontend team sharing the same release cycle as the backend team wants to see nearly every change, even minor internal refactors, because it directly affects their next sprint. An external partner who only adapts to a new API version every few months, on the other hand, mainly wants to know what actually changed for them since their last integrated version, and is more likely to be put off than informed by a long list of purely internal refactors. An automatically generated changelog should therefore not be treated as a single, universal document, it should be treated as a structured data source from which different views can be filtered.
Technically this works well through an additional attribute per changelog entry, say a visibility tag like audience: internal or audience: public, set already during generation from the OpenAPI diff, for example by automatically classifying every change to endpoints marked internal as internal. The public changelog endpoint then consistently filters on audience: public, while the internal CHANGELOG.md in the repository keeps the full, unfiltered list. This small extra effort during generation saves the editor from having to decide by hand, on every release, which entries matter to which audience.
7. The path from automation to editorial follow up
In a working workflow, the changelog draft is generated automatically for every release candidate and lands as a pull request, or as a comment on the existing release pull request, instead of being published directly. A technical editor, or in smaller teams the responsible developer themselves, reads through this draft, adds migration recommendations for breaking changes, removes irrelevant internal changes, and rephrases the automatically generated sentences into a consistent, customer friendly tone. Given a well prepared draft, this step usually takes well under an hour, while writing a changelog entirely by hand for a larger API can easily eat up half a day.
It is important not to treat this editorial step as optional, even when the automatic draft already reads reasonably well. A purely machine generated changelog often feels technical and impersonal to readers, while a short editorial pass, for example adding an introductory sentence about the most important change in the release, makes the whole text noticeably more approachable. The combination of reliable structural completeness and targeted human polish produces a result that neither a purely automated nor a purely manual approach could achieve on its own.
8. Integrating this into the existing release process
To keep changelog generation from becoming yet another manual step someone can forget, it should be wired directly into the CI pipeline. A sensible flow looks like this: on every tag that corresponds to a new API version, a job runs that compares the archived previous OpenAPI specification against the current one, generates structured entries via oasdiff changelog, renders them into Markdown, and opens a draft pull request against a dedicated CHANGELOG.md branch. A reviewer then edits this pull request rather than starting from a blank page, and only merges it after the review.
This flow has a pleasant side effect: because the changelog draft already exists before the actual release, a reviewer going through it often notices when a change is not as harmless as it appeared during code review, for example because it suddenly shows up marked as a breaking change in the consolidated overview. The changelog thereby becomes not just a communication tool for consumers, but an additional last line of control before publication, surfacing structural surprises before they cause damage in production.
9. Format and distribution of the finished changelog
The final changelog should exist in several formats at once, because different consumers prefer different access paths. A CHANGELOG.md file in the repository serves internal developers and links directly from pull requests, while a publicly reachable version, say a dedicated GET /api/changelog endpoint returning JSON, lets external partners monitor changes programmatically and, for example, trigger an internal notification automatically whenever a new breaking change appears. An additional RSS or Atom feed reaches developers who want to follow the changelog in their usual feed reader instead of manually visiting a documentation page.
For most Symfony projects it is enough to expose the changelog, which already exists as Markdown, as JSON through a simple controller action that parses the Markdown file and breaks it into structured entries with date, version, and category. What matters is consistently sorting the changelog by version number and never deleting old entries, since teams tracking an upgrade across several versions in particular rely on a gap free history to identify every relevant breaking change between their current and their target version.
| oasdiff change type | Severity | Changelog category | Example text |
|---|---|---|---|
| endpoint-added | info | New features | New endpoint POST /api/v2/orders/{id}/cancel added |
| request-property-added (optional) | info | New features | Optional parameter discountCode added |
| response-property-removed | error | Breaking changes | Field legacyId removed from the response |
| response-property-type-changed | error | Breaking changes | Field price changed from string to number |
| request-property-became-required | error | Breaking changes | Parameter tenantId is now required |
| schema-description-changed | info | Other changes | A schema description was clarified |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
API Changelog from OpenAPI Diffs: The Key Points at a Glance
Structural diff
oasdiff compares two OpenAPI documents and returns categorized, machine readable changes.
Automatic draft
Every change type turns into a preformulated changelog sentence via a template.
Editorial pass
Context, migration hints, and tone deliberately come from a human, not from the diff.
CI integration
The draft is generated automatically per release and lands as a pull request for review.