carlos / platform Public
🤖 The platform changelog (carlosframework.com/platform/changelog)
Status: rev 2, 2026-10-06. Rev 1 had Astra's review (accept with fixes, CODEX-FINDINGS-CHANGELOG-1.md); every finding is taken and marked (astra N). The first real run over all 28 tags added three more, marked (run). Asked for by Paul the same day: "a page which lists every change for every release, links to docs, links to the Amadan branch/discussion and shows who did the work".
Rulings (Paul, 2026-10-06; not to reopen)
- A change is a merged branch. Each release lists every first-parent commit that landed on
mainsince the previous tag, which since v0.20.0 is one merged branch each. Groundwork, docs and fixes all count. The release's published notes sit on top as the summary. - Every release, back to v0.1.0, with a sliding link depth. From the Amadan era on, a change links to its Amadan branch and discussions. Before that it links to its commit on Amadan (public) and credits the git authors. The GitHub mirror is private, so no GitHub PR links.
- Change lines are records, not copy. A change shows its branch name and squash subject verbatim, styled as data, under a visible 🤖. Only the page's own framing and the already-reviewed release notes are prose. The framing goes through copy review.
- The join runs in the platform repo. A generator writes a committed
docs/site/changelog.json; the website vendors it with the docs corpus and renders it at build time. No network at website build.
What exists (verified 2026-10-06)
- 28 tags, v0.1.0 (2026-08-02) to v0.24.2. v0.2.0 and v0.22.1 were tagged but never published on
carlosframework/releases. - Merge shapes on
main's first-parent line: amadan squashes ("Squashed from X into main." in the body), amadan merges ("Merge branch 'X' into main"), GitHub merges ("Merge pull request #N from carlosframework/X"), and before those, direct commits. All 55 first-parent commits since v0.20.0 name their branch. - Amadan keeps a tombstone ref for each merged branch at
refs/amadan/closed/<branch>(151 today), so a squashed branch's own commits stay reachable. Its page ishttps://amadan.net/carlos/platform/-/branch/<url-escaped name>; a commit ishttps://amadan.net/carlos/platform/commit/<sha>; a discussion ishttps://amadan.net/carlos/platform/-/discussion/<id>. amadan discuss show carlos/platform <id> -jsonandamadan discuss list carlos/platform -branch <b> -state all -jsonread discussions. The unfiltered list caps at 50. There is no read for a branch's description.- Authors: people (
Paul Campbell,Bill Horsman,James Adam,Tony Wood, under several emails) and agents whose email carries their operator (claude~paul@keymail.dev,codex~paul@keymail.dev,claude-code~bill@keymail.dev). Older person-authored commits carryCo-Authored-By: Claude …trailers. - Release notes changed shape. v0.19.0 on: a
carlos vX (platform …)line,- **Bold lead.** …bullets, an operator section (**For operators**,**Before you roll**,**Before you roll a console to this release**), and a closing "Verify a download…" line. Before v0.19.0: free markdown with##headings, tables, fences, an## Installsection and a "🤖 Generated with Claude Code" line. - carlosframework.com is
carlosframework/website(Eleventy, plain CSS, no browser JS but the AI disclaimer).hack/sync-docs.mjsvendorsdocs/site/from a platform checkout;hack/check-docs.mjschecks the build's internal links. Its rule: every AI-written page carries a visible 🤖, and one on the top heading covers the page.
The data: docs/site/changelog.json
{
"//": "generated by go run ./cmd/changelog; do not edit",
"repo": "https://amadan.net/carlos/platform",
"releases": [
{
"tag": "v0.24.2",
"date": "2026-10-06",
"sha": "7f403a77…",
"published": true,
"notes_url": "https://github.com/carlosframework/releases/releases/tag/v0.24.2",
"notes": {
"form": "bullets",
"sections": [
{"title": "", "bullets": [
{"md": "**CI can deploy through the console.** …",
"branches": ["design-deploy-credential"],
"docs": ["/docs/cli#carlos-services"]}
]},
{"title": "For operators", "bullets": [ … ]}
]
},
"changes": [
{
"sha": "d1dab9a9…",
"date": "2026-10-06",
"subject": "deploy-credential: a rung-scoped deploy credential so CI promotes through the console (#229)",
"branch": "design-deploy-credential",
"branch_url": "https://amadan.net/carlos/platform/-/branch/design-deploy-credential",
"commits": 14,
"discussions": [{"id": 229, "title": "CI cannot promote …", "state": "closed"}],
"docs": ["/docs/cli#carlos-services"],
"credit": [{"person": "Paul Campbell", "agents": ["claude"]}]
}
]
}
]
}notes.formis"bullets"for the v0.19.0-on shape and"markdown"otherwise. A markdown body is kept whole asnotes.md, with headings demoted two levels (the release is the page'sh2) and the boilerplate removed: thecarlos vX (platform …)line, the "Verify a download" line, an## Installsection, and the "Generated with Claude Code" line. Unpublished tags have"notes": null, and so does a release published with an empty body (v0.18.0). The gates accept exactly the two forms the page renders, so a badformcannot drop a release's notes silently.- Bullet sections keep the notes' own operator heading text, minus the bold markers.
subjectis the commit's subject line.branch_urlis set only when the branch has an Amadan record (a tombstone ref, a live head, or a squash/merge made on Amadan). A GitHub-era branch keeps itsbranchname with no link.- No email address and no link into a private repo ever appears in the file (astra 3). Commit subjects and discussion titles are quoted with any address replaced by
[address]and any private-repo URL by[private repo]. Discussion #147's title names a customer's address, and a 2026-08-03 sync merge names the private mirror. Anything left, in any field, fails the run:CheckPublic, shared by the generator and the gate.
The generator: cmd/changelog, logic in internal/changelog
go run ./cmd/changelog [-out docs/site/changelog.json]
Run from a full checkout after git fetch origin --tags '+refs/amadan/closed/*:refs/amadan/closed/*'. It needs gh (release bodies) and amadan (discussions) signed in. It writes the file and prints one summary line per release.
Inputs, each behind an interface so tests run on fakes and a scratch git repo:
Git: tags with dates, first-parent commits in a range, a commit's message, parents and changeddocs/site/*.mdhunks, and the commits reachable from a tombstone but not frommain.Releases: release body by tag, or not-found.Discussions: one by id, and the list for a branch.
Ranges (run). Tags sort by semver. A published release covers the first-parent commits no earlier published release reaches; an unpublished tag covers those no earlier tag of any kind reaches (git log --first-parent <tag> ^<base>…). So v0.22.2 lists v0.22.1's change, which it shipped, and v0.3.0, cut from a commit unpublished v0.2.0 already held, lists what it actually shipped. A commit listed under two tags is the same change, joined once.
Branch name, from the first match in the message: Squashed from (\S+) into, Merge branch '([^']+)', Merge pull request #\d+ from [^/\s]+/(\S+). None: a direct commit, branch empty.
The branch's own commits, for credit and discussion references:
- a two-parent merge:
<first parent>..<second parent>; - a squash with a tombstone
refs/amadan/closed/<branch>no newer than the squash:<squash>^..<tombstone>, non-merge commits only; - otherwise the commit itself.
Releases and their commits are joined oldest first, and any commit an earlier change already counted is dropped from a later one's own commits (astra 2). Otherwise a branch stacked on one squashed before it (4d13aec3 on 99084d7a) re-counts the earlier branch's 17 commits, because squashing gave them new ids on main.
The line a change shows (run) is its landing commit's subject, unless that only names the branch ("fix-x", "Merge branch 'x' into main"). Then it is the first informative line of the body (a GitHub PR title, an Amadan merge's description), and failing that the subject of the branch's first commit. Every option is a verbatim line from history. "Merge branch 'main' of <url>" is a sync merge, not a branch.
Credit. For each own commit, read the author and every Co-Authored-By trailer. An identity resolves to a person, an agent, or both:
<agent>~<keymail>: agent<agent>(claude-codereadsclaude), person =people[<keymail>].noreply@anthropic.comor a name startingClaude: agentclaude, no person.codexnames: agentcodex.- Anything else: person =
people[<email>]. noreply@github.comandledger@amadanare skipped.
docs/site/people.json maps every email and keymail to a display name. An identity with no entry fails the run and names the address, so a new contributor is a one-line decision, not a leaked email. Each identity keeps its own pairing: an agent account credits its operator with that agent, and a person co-author is credited alone. An agent with no person of its own (a Co-Authored-By: Claude trailer) attaches to the commit's author, or failing that the first co-author who is a person, never to every person on the commit (Astra's code review: da866a92 had credited Tony Wood "with claude"). Credit groups by person, ordered by commit count, agents listed per person; an agent with no person anywhere is its own entry.
Discussions. References come from the squash message and the own commits' messages: #(\d+), discussion (\d+), amadan #(\d+). A number counts only when discuss show finds it and it was created before the commit, which rejects GitHub-era issue and PR numbers. Plus every discussion discuss list -branch <branch> returns. Deduped, sorted by id. Each lookup is cached for the run.
Docs links. For a change: each docs/site/<page>.md the commit (the squash, or the merge's diff against its first parent) adds lines to, mapped to the nearest enclosing heading of level two to four (the levels the website anchors) in the new file, then kept only if today's corpus still anchors that heading (astra 1). A line under the page's h1 links the page, unless a section of the same page is already linked. A page that no longer exists drops out. For a release-note bullet: each inline code span starting carlos <verb> whose longest matching CLI heading (carlos <verb>, carlos <verb> <sub>) exists on the CLI page. Deduped, at most five per change.
Bullet to branch links. docs/site/changelog-links.json, hand-kept:
{"v0.24.2": {"CI can deploy through the console.": ["design-deploy-credential"]}}keyed by the bullet's bold lead. The generator fails on a key that matches no bullet in that release, or a branch that is not one of that release's changes. Bullets without an entry have no branch links. The backfill covers v0.19.0 on; older notes are markdown and get none.
The page: /platform/changelog/
In carlosframework/website:
hack/sync-docs.mjsvendorsdocs/site/changelog.jsonassrc/_data/docschangelog.jsonwith the usual"//"notice (the sync already ownssrc/_data/docs*.json).src/changelog.njkwithpermalink: /platform/changelog/index.html, in the platform page's world: its masthead, the platform blue accent,site.css, a page-local<style>only wheresite.csshas nothing.- The top heading carries the visible 🤖, which covers the page. A short intro (copy-reviewed) says what a change is, that lines are verbatim records, and how far back links go.
- One
<section id="v0-24-2">per release, newest first: the tag ash2with a self link, date, a link to the published notes (or "not published"), then the notes (bullets with their branch and docs links; the operator section in a<details>; markdown notes rendered by the site's markdown-it), then "Changes (N)" as a list. Each change: branch name (linked when it has a record) or short SHA for a direct commit, the subject in mono, discussions as#229links titled with the discussion title, docs links, the credit ("Paul Campbell with claude"), and the short SHA linked to its commit. - A release with more than 25 changes shows the first 25 and the rest in a
<details>. No script. /platform/gets one link to the changelog (copy-reviewed).hack/check-docs.mjsalso checks that the page built, that every release has its section id, and that every/docslink on it resolves, using the code it already has for docs pages.
Gates and upkeep
internal/changelog/committed_test.gogates the committed file. It parses with no unknown fields; tags are unique and semver-ordered newest first; published, notes and notes URL agree; every/docslink (change, bullet, and markdown links in notes) resolves to a page and an anchored heading;CheckPublicpasses; everychangelog-links.jsonkey matches a bullet whose branches agree.- Completeness (astra 4). In a checkout with tags, every tag up to the newest release in the file must be in it, and each release's changes must equal git's first-parent commits under the range rule. A tag newer than the file is a release not yet regenerated, and is only logged. Amadan CI clones at depth 1 without tags, so there the test skips and says why. It runs in any full checkout, including the one the release recipe regenerates from. Astra suggested committing an inventory for CI instead; an inventory the generator writes is not independent of the generator, so it was not added.
internal/changeloghas unit tests for each parser and resolver on fakes, and one test on a scratch git repo that builds tags, a squash with a tombstone, a two-parent merge and a direct commit, and checks the whole output.- The README's release recipe gains a step after Publish: regenerate the changelog, add the new release's bullet links, commit on a branch, merge, then re-sync and ship the website. (Copy-reviewed.)
Asks of Amadan (filed, not worked around)
- A read for a branch's metadata, including merged ones: description, landed SHA, who pushed. Until then a change's line is its squash subject.
- Paging on
discuss list(it caps at 50). The generator does not need it: it reads by id and by branch.
Out of scope
- An RSS or Atom feed. It's easy to add from the same data later.
- Per-change prose summaries (ruling 3).
- Changing how release notes are written.
- Rolling or deploying anything beyond the website's own ship and promote, which waits for the copy review.