| 1 | # 🤖 The platform changelog (carlosframework.com/platform/changelog) |
| 2 | |
| 3 | Status: rev 2, 2026-10-06. Rev 1 had Astra's review (accept with fixes, |
| 4 | `CODEX-FINDINGS-CHANGELOG-1.md`); every finding is taken and marked |
| 5 | **(astra N)**. The first real run over all 28 tags added three more, |
| 6 | marked **(run)**. Asked for by Paul the same day: "a page which |
| 7 | lists every change for every release, links to docs, links to the Amadan |
| 8 | branch/discussion and shows who did the work". |
| 9 | |
| 10 | ## Rulings (Paul, 2026-10-06; not to reopen) |
| 11 | |
| 12 | 1. **A change is a merged branch.** Each release lists every first-parent |
| 13 | commit that landed on `main` since the previous tag, which since v0.20.0 |
| 14 | is one merged branch each. Groundwork, docs and fixes all count. The |
| 15 | release's published notes sit on top as the summary. |
| 16 | 2. **Every release, back to v0.1.0, with a sliding link depth.** From the |
| 17 | Amadan era on, a change links to its Amadan branch and discussions. |
| 18 | Before that it links to its commit on Amadan (public) and credits the git |
| 19 | authors. The GitHub mirror is private, so no GitHub PR links. |
| 20 | 3. **Change lines are records, not copy.** A change shows its branch name |
| 21 | and squash subject verbatim, styled as data, under a visible 🤖. Only |
| 22 | the page's own framing and the already-reviewed release notes are prose. |
| 23 | The framing goes through copy review. |
| 24 | 4. **The join runs in the platform repo.** A generator writes a committed |
| 25 | `docs/site/changelog.json`; the website vendors it with the docs corpus |
| 26 | and renders it at build time. No network at website build. |
| 27 | |
| 28 | ## What exists (verified 2026-10-06) |
| 29 | |
| 30 | - 28 tags, v0.1.0 (2026-08-02) to v0.24.2. v0.2.0 and v0.22.1 were tagged |
| 31 | but never published on `carlosframework/releases`. |
| 32 | - Merge shapes on `main`'s first-parent line: amadan squashes ("Squashed |
| 33 | from X into main." in the body), amadan merges ("Merge branch 'X' into |
| 34 | main"), GitHub merges ("Merge pull request #N from carlosframework/X"), |
| 35 | and before those, direct commits. All 55 first-parent commits since |
| 36 | v0.20.0 name their branch. |
| 37 | - Amadan keeps a tombstone ref for each merged branch at |
| 38 | `refs/amadan/closed/<branch>` (151 today), so a squashed branch's own |
| 39 | commits stay reachable. Its page is `https://amadan.net/carlos/platform/-/branch/<url-escaped name>`; |
| 40 | a commit is `https://amadan.net/carlos/platform/commit/<sha>`; a |
| 41 | discussion is `https://amadan.net/carlos/platform/-/discussion/<id>`. |
| 42 | - `amadan discuss show carlos/platform <id> -json` and `amadan discuss |
| 43 | list carlos/platform -branch <b> -state all -json` read discussions. The |
| 44 | unfiltered list caps at 50. There is no read for a branch's description. |
| 45 | - Authors: people (`Paul Campbell`, `Bill Horsman`, `James Adam`, `Tony |
| 46 | Wood`, under several emails) and agents whose email carries their |
| 47 | operator (`claude~paul@keymail.dev`, `codex~paul@keymail.dev`, |
| 48 | `claude-code~bill@keymail.dev`). Older person-authored commits carry |
| 49 | `Co-Authored-By: Claude …` trailers. |
| 50 | - Release notes changed shape. v0.19.0 on: a `carlos vX (platform …)` line, |
| 51 | `- **Bold lead.** …` bullets, an operator section (`**For operators**`, |
| 52 | `**Before you roll**`, `**Before you roll a console to this release**`), |
| 53 | and a closing "Verify a download…" line. Before v0.19.0: free markdown |
| 54 | with `##` headings, tables, fences, an `## Install` section and a |
| 55 | "🤖 Generated with Claude Code" line. |
| 56 | - carlosframework.com is `carlosframework/website` (Eleventy, plain CSS, no |
| 57 | browser JS but the AI disclaimer). `hack/sync-docs.mjs` vendors |
| 58 | `docs/site/` from a platform checkout; `hack/check-docs.mjs` checks |
| 59 | the build's internal links. Its rule: every AI-written page carries a |
| 60 | visible 🤖, and one on the top heading covers the page. |
| 61 | |
| 62 | ## The data: `docs/site/changelog.json` |
| 63 | |
| 64 | ```json |
| 65 | { |
| 66 | "//": "generated by go run ./cmd/changelog; do not edit", |
| 67 | "repo": "https://amadan.net/carlos/platform", |
| 68 | "releases": [ |
| 69 | { |
| 70 | "tag": "v0.24.2", |
| 71 | "date": "2026-10-06", |
| 72 | "sha": "7f403a77…", |
| 73 | "published": true, |
| 74 | "notes_url": "https://github.com/carlosframework/releases/releases/tag/v0.24.2", |
| 75 | "notes": { |
| 76 | "form": "bullets", |
| 77 | "sections": [ |
| 78 | {"title": "", "bullets": [ |
| 79 | {"md": "**CI can deploy through the console.** …", |
| 80 | "branches": ["design-deploy-credential"], |
| 81 | "docs": ["/docs/cli#carlos-services"]} |
| 82 | ]}, |
| 83 | {"title": "For operators", "bullets": [ … ]} |
| 84 | ] |
| 85 | }, |
| 86 | "changes": [ |
| 87 | { |
| 88 | "sha": "d1dab9a9…", |
| 89 | "date": "2026-10-06", |
| 90 | "subject": "deploy-credential: a rung-scoped deploy credential so CI promotes through the console (#229)", |
| 91 | "branch": "design-deploy-credential", |
| 92 | "branch_url": "https://amadan.net/carlos/platform/-/branch/design-deploy-credential", |
| 93 | "commits": 14, |
| 94 | "discussions": [{"id": 229, "title": "CI cannot promote …", "state": "closed"}], |
| 95 | "docs": ["/docs/cli#carlos-services"], |
| 96 | "credit": [{"person": "Paul Campbell", "agents": ["claude"]}] |
| 97 | } |
| 98 | ] |
| 99 | } |
| 100 | ] |
| 101 | } |
| 102 | ``` |
| 103 | |
| 104 | - `notes.form` is `"bullets"` for the v0.19.0-on shape and `"markdown"` |
| 105 | otherwise. A markdown body is kept whole as `notes.md`, with headings |
| 106 | demoted two levels (the release is the page's `h2`) and the |
| 107 | boilerplate removed: the `carlos vX (platform …)` line, the "Verify a |
| 108 | download" line, an `## Install` section, and the "Generated with Claude |
| 109 | Code" line. Unpublished tags have `"notes": null`, and so does a |
| 110 | release published with an empty body (v0.18.0). The gates accept |
| 111 | exactly the two forms the page renders, so a bad `form` cannot drop a |
| 112 | release's notes silently. |
| 113 | - Bullet sections keep the notes' own operator heading text, minus the |
| 114 | bold markers. |
| 115 | - `subject` is the commit's subject line. `branch_url` is set only when |
| 116 | the branch has an Amadan record (a tombstone ref, a live head, or a |
| 117 | squash/merge made on Amadan). A GitHub-era branch keeps its `branch` |
| 118 | name with no link. |
| 119 | - No email address and no link into a private repo ever appears in the |
| 120 | file **(astra 3)**. Commit subjects and discussion titles are quoted |
| 121 | with any address replaced by `[address]` and any private-repo URL by |
| 122 | `[private repo]`. Discussion #147's title names a customer's address, |
| 123 | and a 2026-08-03 sync merge names the private mirror. Anything left, |
| 124 | in any field, fails the run: `CheckPublic`, shared by the generator |
| 125 | and the gate. |
| 126 | |
| 127 | ## The generator: `cmd/changelog`, logic in `internal/changelog` |
| 128 | |
| 129 | `go run ./cmd/changelog [-out docs/site/changelog.json]` |
| 130 | |
| 131 | Run from a full checkout after `git fetch origin --tags |
| 132 | '+refs/amadan/closed/*:refs/amadan/closed/*'`. It needs `gh` (release |
| 133 | bodies) and `amadan` (discussions) signed in. It writes the file and |
| 134 | prints one summary line per release. |
| 135 | |
| 136 | **Inputs, each behind an interface** so tests run on fakes and a scratch |
| 137 | git repo: |
| 138 | |
| 139 | - `Git`: tags with dates, first-parent commits in a range, a commit's |
| 140 | message, parents and changed `docs/site/*.md` hunks, and the commits |
| 141 | reachable from a tombstone but not from `main`. |
| 142 | - `Releases`: release body by tag, or not-found. |
| 143 | - `Discussions`: one by id, and the list for a branch. |
| 144 | |
| 145 | **Ranges (run).** Tags sort by semver. A published release covers the |
| 146 | first-parent commits no earlier *published* release reaches; an |
| 147 | unpublished tag covers those no earlier tag of any kind reaches |
| 148 | (`git log --first-parent <tag> ^<base>…`). So v0.22.2 lists v0.22.1's |
| 149 | change, which it shipped, and v0.3.0, cut from a commit unpublished |
| 150 | v0.2.0 already held, lists what it actually shipped. A commit listed |
| 151 | under two tags is the same change, joined once. |
| 152 | |
| 153 | **Branch name**, from the first match in the message: `Squashed from (\S+) |
| 154 | into`, `Merge branch '([^']+)'`, `Merge pull request #\d+ from |
| 155 | [^/\s]+/(\S+)`. None: a direct commit, `branch` empty. |
| 156 | |
| 157 | **The branch's own commits**, for credit and discussion references: |
| 158 | |
| 159 | - a two-parent merge: `<first parent>..<second parent>`; |
| 160 | - a squash with a tombstone `refs/amadan/closed/<branch>` no newer than |
| 161 | the squash: `<squash>^..<tombstone>`, non-merge commits only; |
| 162 | - otherwise the commit itself. |
| 163 | |
| 164 | Releases and their commits are joined oldest first, and any commit an |
| 165 | earlier change already counted is dropped from a later one's own |
| 166 | commits **(astra 2)**. Otherwise a branch stacked on one squashed before |
| 167 | it (`4d13aec3` on `99084d7a`) re-counts the earlier branch's 17 commits, |
| 168 | because squashing gave them new ids on main. |
| 169 | |
| 170 | **The line a change shows (run)** is its landing commit's subject, |
| 171 | unless that only names the branch ("fix-x", "Merge branch 'x' into |
| 172 | main"). Then it is the first informative line of the body (a GitHub PR |
| 173 | title, an Amadan merge's description), and failing that the subject of |
| 174 | the branch's first commit. Every option is a verbatim line from |
| 175 | history. "Merge branch 'main' of <url>" is a sync merge, not a branch. |
| 176 | |
| 177 | **Credit.** For each own commit, read the author and every |
| 178 | `Co-Authored-By` trailer. An identity resolves to a person, an agent, or |
| 179 | both: |
| 180 | |
| 181 | - `<agent>~<keymail>`: agent `<agent>` (`claude-code` reads `claude`), |
| 182 | person = `people[<keymail>]`. |
| 183 | - `noreply@anthropic.com` or a name starting `Claude`: agent `claude`, no |
| 184 | person. `codex` names: agent `codex`. |
| 185 | - Anything else: person = `people[<email>]`. |
| 186 | - `noreply@github.com` and `ledger@amadan` are skipped. |
| 187 | |
| 188 | `docs/site/people.json` maps every email and keymail to a display name. |
| 189 | An identity with no entry fails the run and names the address, so a new |
| 190 | contributor is a one-line decision, not a leaked email. Each identity |
| 191 | keeps its own pairing: an agent account credits its operator with that |
| 192 | agent, and a person co-author is credited alone. An agent with no |
| 193 | person of its own (a `Co-Authored-By: Claude` trailer) attaches to the |
| 194 | commit's author, or failing that the first co-author who is a person, |
| 195 | never to every person on the commit (Astra's code review: `da866a92` |
| 196 | had credited Tony Wood "with claude"). Credit groups by person, ordered |
| 197 | by commit count, agents listed per person; an agent with no person |
| 198 | anywhere is its own entry. |
| 199 | |
| 200 | **Discussions.** References come from the squash message and the own |
| 201 | commits' messages: `#(\d+)`, `discussion (\d+)`, `amadan #(\d+)`. A |
| 202 | number counts only when `discuss show` finds it and it was created before |
| 203 | the commit, which rejects GitHub-era issue and PR numbers. Plus every |
| 204 | discussion `discuss list -branch <branch>` returns. Deduped, sorted by id. |
| 205 | Each lookup is cached for the run. |
| 206 | |
| 207 | **Docs links.** For a change: each `docs/site/<page>.md` the commit (the |
| 208 | squash, or the merge's diff against its first parent) adds lines to, |
| 209 | mapped to the nearest enclosing heading of level two to four (the levels |
| 210 | the website anchors) in the new file, then kept only if today's corpus |
| 211 | still anchors that heading **(astra 1)**. A line under the page's h1 |
| 212 | links the page, unless a section of the same page is already linked. A |
| 213 | page that no longer exists drops out. For a |
| 214 | release-note bullet: each inline code span starting `carlos <verb>` whose |
| 215 | longest matching CLI heading (`carlos <verb>`, `carlos <verb> <sub>`) exists |
| 216 | on the CLI page. Deduped, at most five per change. |
| 217 | |
| 218 | **Bullet to branch links.** `docs/site/changelog-links.json`, hand-kept: |
| 219 | |
| 220 | ```json |
| 221 | {"v0.24.2": {"CI can deploy through the console.": ["design-deploy-credential"]}} |
| 222 | ``` |
| 223 | |
| 224 | keyed by the bullet's bold lead. The generator fails on a key that |
| 225 | matches no bullet in that release, or a branch that is not one of that |
| 226 | release's changes. Bullets without an entry have no branch links. The |
| 227 | backfill covers v0.19.0 on; older notes are markdown and get none. |
| 228 | |
| 229 | ## The page: `/platform/changelog/` |
| 230 | |
| 231 | In `carlosframework/website`: |
| 232 | |
| 233 | - `hack/sync-docs.mjs` vendors `docs/site/changelog.json` as |
| 234 | `src/_data/docschangelog.json` with the usual `"//"` notice (the sync |
| 235 | already owns `src/_data/docs*.json`). |
| 236 | - `src/changelog.njk` with `permalink: /platform/changelog/index.html`, |
| 237 | in the platform page's world: its masthead, the platform blue accent, |
| 238 | `site.css`, a page-local `<style>` only where `site.css` has nothing. |
| 239 | - The top heading carries the visible 🤖, which covers the page. A short |
| 240 | intro (copy-reviewed) says what a change is, that lines are verbatim |
| 241 | records, and how far back links go. |
| 242 | - One `<section id="v0-24-2">` per release, newest first: the tag as `h2` |
| 243 | with a self link, date, a link to the published notes (or "not |
| 244 | published"), then the notes (bullets with their branch and docs links; |
| 245 | the operator section in a `<details>`; markdown notes rendered by the |
| 246 | site's markdown-it), then "Changes (N)" as a list. Each change: branch |
| 247 | name (linked when it has a record) or short SHA for a direct commit, |
| 248 | the subject in mono, discussions as `#229` links titled with the |
| 249 | discussion title, docs links, the credit ("Paul Campbell with claude"), |
| 250 | and the short SHA linked to its commit. |
| 251 | - A release with more than 25 changes shows the first 25 and the rest in |
| 252 | a `<details>`. No script. |
| 253 | - `/platform/` gets one link to the changelog (copy-reviewed). |
| 254 | - `hack/check-docs.mjs` also checks that the page built, that every |
| 255 | release has its section id, and that every `/docs` link on it |
| 256 | resolves, using the code it already has for docs pages. |
| 257 | |
| 258 | ## Gates and upkeep |
| 259 | |
| 260 | - `internal/changelog/committed_test.go` gates the committed file. It |
| 261 | parses with no unknown fields; tags are unique and semver-ordered |
| 262 | newest first; published, notes and notes URL agree; every `/docs` link |
| 263 | (change, bullet, and markdown links in notes) resolves to a page and an |
| 264 | anchored heading; `CheckPublic` passes; every `changelog-links.json` |
| 265 | key matches a bullet whose branches agree. |
| 266 | - **Completeness (astra 4).** In a checkout with tags, every tag up to |
| 267 | the newest release in the file must be in it, and each release's |
| 268 | changes must equal git's first-parent commits under the range rule. A |
| 269 | tag newer than the file is a release not yet regenerated, and is only |
| 270 | logged. Amadan CI clones at depth 1 without tags, so there the test |
| 271 | skips and says why. It runs in any full checkout, including the one |
| 272 | the release recipe regenerates from. Astra suggested committing an |
| 273 | inventory for CI instead; an inventory the generator writes is not |
| 274 | independent of the generator, so it was not added. |
| 275 | - `internal/changelog` has unit tests for each parser and resolver on |
| 276 | fakes, and one test on a scratch git repo that builds tags, a squash with |
| 277 | a tombstone, a two-parent merge and a direct commit, and checks the whole |
| 278 | output. |
| 279 | - The README's release recipe gains a step after Publish: regenerate the |
| 280 | changelog, add the new release's bullet links, commit on a branch, merge, |
| 281 | then re-sync and ship the website. (Copy-reviewed.) |
| 282 | |
| 283 | ## Asks of Amadan (filed, not worked around) |
| 284 | |
| 285 | 1. A read for a branch's metadata, including merged ones: description, |
| 286 | landed SHA, who pushed. Until then a change's line is its squash |
| 287 | subject. |
| 288 | 2. Paging on `discuss list` (it caps at 50). The generator does not need |
| 289 | it: it reads by id and by branch. |
| 290 | |
| 291 | ## Out of scope |
| 292 | |
| 293 | - An RSS or Atom feed. It's easy to add from the same data later. |
| 294 | - Per-change prose summaries (ruling 3). |
| 295 | - Changing how release notes are written. |
| 296 | - Rolling or deploying anything beyond the website's own ship and |
| 297 | promote, which waits for the copy review. |
| 298 | |