# 🤖 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)

1. **A change is a merged branch.** Each release lists every first-parent
   commit that landed on `main` since 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.
2. **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.
3. **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.
4. **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 is `https://amadan.net/carlos/platform/-/branch/<url-escaped name>`;
  a commit is `https://amadan.net/carlos/platform/commit/<sha>`; a
  discussion is `https://amadan.net/carlos/platform/-/discussion/<id>`.
- `amadan discuss show carlos/platform <id> -json` and `amadan discuss
  list carlos/platform -branch <b> -state all -json` read 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 carry
  `Co-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 `## Install` section 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.mjs` vendors
  `docs/site/` from a platform checkout; `hack/check-docs.mjs` checks
  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`

```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.form` is `"bullets"` for the v0.19.0-on shape and `"markdown"`
  otherwise. A markdown body is kept whole as `notes.md`, with headings
  demoted two levels (the release is the page's `h2`) and the
  boilerplate removed: the `carlos vX (platform …)` line, the "Verify a
  download" line, an `## Install` section, 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 bad `form` cannot drop a
  release's notes silently.
- Bullet sections keep the notes' own operator heading text, minus the
  bold markers.
- `subject` is the commit's subject line. `branch_url` is 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 its `branch`
  name 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 changed `docs/site/*.md` hunks, and the commits
  reachable from a tombstone but not from `main`.
- `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-code` reads `claude`),
  person = `people[<keymail>]`.
- `noreply@anthropic.com` or a name starting `Claude`: agent `claude`, no
  person. `codex` names: agent `codex`.
- Anything else: person = `people[<email>]`.
- `noreply@github.com` and `ledger@amadan` are 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:

```json
{"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.mjs` vendors `docs/site/changelog.json` as
  `src/_data/docschangelog.json` with the usual `"//"` notice (the sync
  already owns `src/_data/docs*.json`).
- `src/changelog.njk` with `permalink: /platform/changelog/index.html`,
  in the platform page's world: its masthead, the platform blue accent,
  `site.css`, a page-local `<style>` only where `site.css` has 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 as `h2`
  with 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 `#229` links 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.mjs` also checks that the page built, that every
  release has its section id, and that every `/docs` link on it
  resolves, using the code it already has for docs pages.

## Gates and upkeep

- `internal/changelog/committed_test.go` gates the committed file. It
  parses with no unknown fields; tags are unique and semver-ordered
  newest first; published, notes and notes URL agree; every `/docs` link
  (change, bullet, and markdown links in notes) resolves to a page and an
  anchored heading; `CheckPublic` passes; every `changelog-links.json`
  key 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/changelog` has 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)

1. 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.
2. 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.
