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 therun: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
urllibputs 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.jsonThe 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"]] = itemPermanent 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_publishedis 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_textinto paragraphs and bullet lists in a filter that returns plain strings, never markup. - Keep
external_urlonly if it starts withhttp://orhttps://. Ajavascript:URL in anhrefruns script. - Escape the JSON-LD block, the structured data search engines read.
JSON.stringifyleaves</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-fileoption, 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
jsonLdfilter, 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.