A changelog RSS feed that works in every reader: a field-by-field checklist

What RSS 2.0 and its best practices profile require of a changelog feed, field by field, and the mistakes that show entries twice or as one run-on paragraph.

6 min read

An RSS item with its title, link, guid, pubDate and description fields, each one checked.

A changelog feed is small enough to write by hand and old enough that its rules are settled.

This checklist goes field by field through what an RSS 2.0 changelog feed needs. It follows the RSS 2.0 specification, which is frozen in its features (the current text is version 2.0.11, published in 2009), and the RSS Advisory Board's best practices profile. The profile's compatibility tests name feed readers that no longer exist, but its rules about encoding and dates are still the safest reading of the spec.

A complete minimal feed

Start from something valid and add to it:

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Acme changelog</title>
    <link>https://acme.example/changelog</link>
    <description>Every Acme release, newest first.</description>
    <language>en</language>
    <atom:link href="https://acme.example/changelog/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>2.4.0: CSV export for invoices</title>
      <link>https://acme.example/changelog#2-4-0</link>
      <guid isPermaLink="false">acme-release-2.4.0</guid>
      <pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate>
      <description>&lt;p&gt;Export any invoice list as CSV.&lt;/p&gt;</description>
    </item>
  </channel>
</rss>

Every element in it has a reason, covered below.

The channel

  • title, link and description are required. The link is the changelog page a reader can open, not the feed itself.
  • Every URL must be absolute. The profile says link and url elements "must not contain relative URLs", because a feed has no base URL to resolve against. Build them from one configured origin, not from the request's host, or a preview deployment will publish links to itself.
  • atom:link rel="self" names the feed's own address. RSS 2.0 has no element for this, so the profile borrows Atom's and says a feed "should contain" it. Declare the Atom namespace on the rss element, as in the example.
  • language is optional and cheap. Readers that hyphenate or translate use it.
  • ttl is optional and mostly ignored. The profile found that readers which support it treat it as a maximum check frequency, not the minimum the spec intended. Rely on HTTP caching instead.

Each item

title

Plain text. The profile says every element except description is read as plain text, so markup in a title shows up as literal angle brackets. Lead with what changed for the reader. A version number alone ("2.4.0") tells a feed reader's list view nothing.

The page where the entry lives, absolute. If your changelog is one long page, an anchor per release works (/changelog#2-4-0). Give each release a stable id on the page so the anchor does not depend on the title.

This is the field that decides whether readers show an entry once or twice. The guid is how a reader recognises an item it has already shown.

The trap is the default. In RSS 2.0, isPermaLink "is optional, its default value is true", and when it is true, the guid "must be the permanent URL of the web page associated with the item." Most hand-written feeds put the item URL in <guid> with no attribute and so promise that the URL is permanent. Then someone renames a heading, the anchor changes, and every subscriber sees the release again as new.

Use an id that never changes and say it is not a URL:

<guid isPermaLink="false">acme-release-2.4.0</guid>

The version number is a good basis, or a database id. Do not build it from anything a person might edit later, such as a title, a slug, or the address of the board the entry was posted on.

pubDate

RFC 822 format, which the spec relaxes to allow a four-digit year: Tue, 13 Oct 2026 00:00:00 GMT. In JavaScript, new Date(...).toUTCString() produces it. The profile adds two practical rules: use one space between parts, and avoid RFC 822's single-letter military time zones other than Z, because, in the profile's words, RFC 822 specified them incorrectly. GMT or a numeric offset such as +0200 is safe.

If your changelog stores only a date, publish midnight UTC consistently, so that rebuilding the feed never moves an entry.

description

The one element that is read as HTML, and the one most often broken. The profile is explicit: the description "must be suitable for presentation as HTML", and the markup "must be encoded as character data", either with entities or a CDATA section.

Two consequences:

  1. Plain text with line breaks collapses. A release note sent as Added\n- CSV export\n- Dark mode renders in most readers as "Added - CSV export - Dark mode" on one line, because HTML ignores newlines. Send <p> and <ul> markup instead.
  2. Text is escaped twice. First as HTML, then as XML. An ampersand in your release note becomes &amp; in the HTML, and that HTML is escaped again for XML, so the feed contains &amp;amp;. That is correct. Escaping only once is the bug.

A worked example. The note is "Fixed: R&D filter ignored <none>". As HTML: <p>Fixed: R&amp;D filter ignored &lt;none&gt;</p>. In the XML:

<description>&lt;p&gt;Fixed: R&amp;amp;D filter ignored &amp;lt;none&amp;gt;&lt;/p&gt;</description>

CDATA avoids the second layer, but a release note that ever contains ]]> will end the section early. Entity-escaping everything is the boring choice that cannot break.

Keep URLs inside the description absolute, for the same reason as the link.

Let people find it

Browsers and readers discover a feed from the page's head. The HTML standard defines this: a link element with rel="alternate" and type set to application/rss+xml "creates a hyperlink referencing a syndication feed".

<link rel="alternate" type="application/rss+xml" title="Acme changelog" href="/changelog/rss.xml">

Add it to the changelog page at least, and put a visible link on the page too. Some readers want a URL to paste.

Serve it politely

The profile notes that by convention most readers check a feed about once an hour. That traffic is cheap if the response can be cached:

  • Content-Type: application/rss+xml; charset=utf-8.
  • A Cache-Control max-age around an hour, since the feed changes when you publish.
  • An ETag or Last-Modified, so a reader's conditional request can be answered with 304 Not Modified (see conditional requests in RFC 9110). Most static hosts add these for you if the feed is built once rather than per request.

A feed is not a page for search results. If your platform would index it, send X-Robots-Tag: noindex with it. The indexing policy for request boards covers the pages around it.

How many entries

RSS has no paging. A reader sees what is in the feed when it checks and remembers what it saw. Twenty or so recent entries is plenty for a changelog. Someone who subscribes today does not need the release notes from 2019, and the page has those. If you do need complete history in feed form, Atom's feed paging and archiving (RFC 5005) is the standard way to do it.

What to write in it

The format is the easy part. A feed of commit messages is valid RSS and useless. A few habits from Keep a Changelog carry straight over: write for people rather than machines, group changes by type (Added, Changed, Fixed, Security and so on), date every release, and leave out changes nobody outside the team could notice.

Title each entry by what a reader can now do ("Export invoices as CSV"), not by what you did internally ("Refactor export service"). And if the entry answers a request people voted on, a feed is not enough: tell them by email, once.

Check it before you ship it

  1. Validate the feed

  2. Subscribe in a real reader

    Add the feed's address to a feed reader you use, as a subscriber would.

  3. Publish a test entry

    Watch it arrive once, because a validator cannot tell you that your entry shows up twice.

The checklist

  • Absolute URLs everywhere, built from one configured origin.
  • atom:link rel="self" with the Atom namespace declared.
  • Plain-text titles that say what changed.
  • guid isPermaLink="false" with an id nobody will edit.
  • RFC 822 dates with a four-digit year, GMT or a numeric offset.
  • Descriptions as HTML paragraphs and lists, entity-escaped.
  • rel="alternate" discovery link in the page head.
  • Cache headers and conditional request support.
  • A recent window of entries, not the whole history.