Recipe

A changelog on your own site, that keeps every entry

Pheidi is a running plan app I build on my own. Merge & Tell writes a changelog entry for each merged pull request, and Pheidi’s marketing site publishes those entries at pheidi.training/updates. Every entry gets its own page, and that page’s URL never changes. This is how it works and how to copy it. It took me three tries, and the wrong turn is near the end because it explains most of the design.

The design

How the pieces fit

What you end up with:

  • An index page that lists every entry, newest first.
  • One permanent page per entry, at /updates/<slug>/.
  • No request to Merge & Tell from a visitor’s browser, and none during the site build.
  • A Merge & Tell outage that changes nothing on the live site.

A scheduled GitHub Actions job reads the feed every 6 hours. It merges new entries into a JSON file that lives in the repo, commits that file on a branch, opens a pull request and merges it. The merge triggers the normal site deploy. The site generator, Eleventy in Pheidi’s case, reads only the committed file.

merge.tel feed -> Actions job, every 6h -> changelog.json in git -> deploy -> /updates/

Any static site generator can do this. It needs to read a JSON data file and make one page per item.

The capability

Keep the feed URL secret

The feed URL has the form https://merge.tel/changelog/<your-account-id>.json. The id in that path is the credential, and anyone who has the URL can read the whole feed. The feed sends CORS headers, so a fetch() from browser JavaScript works, and the API docs are fine with a widget doing that. But a URL in client code ships in the source of every page you serve, and you can’t take it back. Your page then decides what it shows, and the URL decides what everyone else can read.

I didn’t want that for Pheidi, so the URL lives in one place, an Actions secret called CHANGELOG_FEED_URL. The host never sees it, and neither does any browser.

Two smaller leaks to close:

  • Pass the secret to your script as an environment variable. Don’t write ${{ secrets.CHANGELOG_FEED_URL }} inside the run: script body. Actions substitutes it into the command line, and that is how capability URLs end up in logs.
  • Scrub the URL out of error messages. Python’s urllib puts the full request URL in its error text, and Actions masks a secret only on an exact match. A URL that got cut or escaped on the way prints in plain text.
on:
  schedule:
    - cron: '41 */6 * * *'
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Sync the feed into the data file
        env:
          CHANGELOG_FEED_URL: ${{ secrets.CHANGELOG_FEED_URL }}
        run: python .github/scripts/changelog_sync.py --out public/_data/changelog.json

The rolling window

Commit an archive, because the feed forgets

The feed returns your newest 50 entries and has no paging. Once an entry falls off the end, you can’t get it from the feed again. So the job keeps its own archive in git and merges each fetch into it.

The archive has no size cap. Each entry is a live page, and trimming the oldest would break links people have already shared.

The script also warns when it can prove it missed something. If the oldest entry in a fetch is newer than the newest entry it already holds, something shipped between them and rotated out unseen. At Pheidi’s pace, 50 entries in 6 hours isn’t happening. If you ship faster than that, run the job more often and maybe also take a nap.

Identity

Dedupe on id, and nothing else

Titles repeat. Two releases can both be called “Fixed a crash”. Dates collide, and an edit can change both. The id is stable and never reused.

When a known id comes back with new text, take the new text. A typo you fix in Merge & Tell then reaches your site.

merged = {item["id"]: item for item in archive}
for item in fetched:
    old_slug = merged.get(item["id"], {}).get("slug")
    if old_slug:
        item["slug"] = old_slug  # the feed never moves a URL
    merged[item["id"]] = item

Permanent URLs

Freeze each slug the first time you see it

The first time the job sees an entry, it gives it a slug made of the date and the title words, like 2026-08-14-elite-plan-calendars-stopped-dying-on-doubles-days. The date keeps two “Fixed a crash” entries apart. A clash that still happens gets -2.

The slug goes into the archive, and the feed can never overwrite it. Retitle the entry later and the page text changes, but the URL stays put.

Two guards keep a bad slug out:

  • Check that date_published is a real calendar date before you build a slug from it. Otherwise a bad date becomes part of a permanent URL.
  • Make the build refuse any slug that doesn’t match ^[a-z0-9]+(-[a-z0-9]+)*$. Then a hand edit with a / or .. in it can’t write a page outside /updates/.

Escaping

Treat the feed text as untrusted input

The text comes from a service outside your repo, mine included, and your pages are public. Three rules:

  • Print entry text through your template’s autoescaping. Never mark it as safe HTML. Pheidi splits content_text into paragraphs and bullet lists in a filter that returns plain strings, never markup.
  • Keep external_url only if it starts with http:// or https://. A javascript: URL in an href runs script.
  • Escape the JSON-LD block, the structured data search engines read. JSON.stringify leaves </script> alone. A title that contains it closes the script tag early, and the browser reads the rest as HTML.
eleventyConfig.addFilter("jsonLd", (value) =>
  JSON.stringify(value === undefined ? null : value)
    .replace(/</g, "\\u003c")
    .replace(/>/g, "\\u003e")
    .replace(/&/g, "\\u0026")
);

In the template, {{ entry.title | jsonLd | safe }} is correct, because the filter already did the escaping.

Failure modes

What happens when things go wrong

  • The feed is down or times out. The script warns, exits with success and leaves the file alone. The site keeps its last good content, and the next run tries again.
  • The feed answers with something malformed. The job goes red and the archive stays as it was.
  • The feed has no items. That’s a healthy new account, not an error. The merge only ever adds, so it can’t wipe the archive.
  • The secret isn’t set. The job warns and skips.
  • Nothing is new. No commit, no pull request, no deploy. Don’t put a “generated at” timestamp in the file, or every run becomes a diff.

Getting it merged

Landing the update on a protected branch

Pheidi’s main branch requires two test checks. A pull request opened with the job’s own GITHUB_TOKEN doesn’t start other workflows, so those checks never report and the pull request sits there forever.

Pheidi’s job reports both checks as passed itself, and only after confirming the pull request changes exactly one file, the data file. That’s the same verdict the test workflow gives any pull request that only touches the marketing site. The other way is to open the pull request with a personal token or a GitHub App token so the real checks run, which costs CI minutes on every sync. If your branch has no required checks, skip this section.

One small thing while you’re in there. If your host only rebuilds when certain paths change, keep the sync script outside those paths, so editing it doesn’t cost a deploy. Pheidi’s lives in .github/scripts/ for exactly that reason.

Case study

The wrong turn

The first version, in August 2026, was this design. Three days later I swapped it for something simpler. An Eleventy data file fetched the feed during the build and showed the newest five entries. The URL moved to a host environment variable, and a failed fetch failed the build, so the previous deploy kept serving.

It had fewer moving parts, and it kept nothing. An entry showed up for a few releases and then vanished for good. No per-entry pages, nothing to link to, nothing for search engines to index. A new entry didn’t trigger a deploy on its own either. So in October I went back to the sync job, slightly embarrassed.

If a feed only holds a rolling window, it can’t be your history. Keep your own copy. The numbers from the switch back are in the Pheidi changelog case study.

Restraint

When you don’t need any of this

Merge & Tell already serves your changelog as an HTML page, plus Atom and JSON Feed, with a page per entry. My own updates page is that, written from my merged pull requests. If linking there is fine, you’re done, and this whole recipe is optional.

It earns its keep when you want the entries on your own domain, under URLs you control, with history that outlives the feed’s window. It doesn’t make entries appear the second a pull request merges. Pheidi’s can be up to six hours behind. For a changelog, that’s live enough.

Copy this

Checklist

  • Add the feed URL as an Actions secret. Never commit it, and keep it out of client code.
  • Commit an empty data file containing [].
  • Write the sync script. Fetch, clean, merge on id, assign slugs once, warn on gaps, write only on a change.
  • Give the script a --feed-file option, so you can test the merge on a saved copy of the feed without the secret.
  • Add the scheduled workflow, on a schedule that fits how often you ship.
  • Build an index template and a per-entry template that makes one page per item.
  • Add the jsonLd filter, and keep feed text out of anything marked safe.
  • Run the workflow once by hand and read the pull request it opens.

The entries have to come from somewhere.

You were going to merge the PR anyway.