The pull request as the unit of change, and a changelog built on it #77

Closed
opened 2026-09-22 15:46:03 +01:00 by cruelacid · 1 comment
Owner

Why

Six plugin versions have shipped to a public directory and a user cannot find
out what changed in any of them.
docs/releasing.md:134 says "Every plugin
release bumps manifest.json with a changelog line";
scripts/bump-plugin-version.mjs touches manifest.json, versions.json and
package.json and writes no changelog. The claim is false, in the same way the
README's install section was.

Designing the fix surfaced the real gap. Every automated changelog worth copying
is built on a unit of change that carries prose — Kubernetes on the pull
request, GitLab on the commit, Forgejo's own release-notes-assistant on the
pull request. This repository appeared to have none, only a stream of commits.

It does have one, unrecognised: the worktree. One plan, one worktree, one
branch. Four are on origin as this is written. Each is a coherent story with a
beginning and an end. It is already the unit of change — it is simply not
addressable.

The decision

One plan → one worktree → one branch → one pull request → fast-forward onto main.

The workflow in CLAUDE.md does not change shape. It gains a merge event.

What this fixes beyond the changelog

1. The gate moves in front of the merge. e2e and promote are both
if: github.ref == 'refs/heads/main' today. The comment gives the reasoning —
"the two minutes of real Obsidian are spent on what is about to be promoted."
Under pull requests the merge is what is about to be promoted, so the same
reasoning puts the gate on the PR, where failure means "do not merge" rather
than "main is already broken and the deploy has been stopped after the fact."

2. It costs less CI, not more — measured. Every PR currently triggers CI
twice: push and pull_request fire on the same sha, confirmed on eight
distinct shas; the repo has 317 push runs against 15 pull_request runs.
Narrowing push: to main removes the duplicate. A branch with no PR then gets
no CI, which is deliberate — opening a PR is how you ask for it. And e2e moves
from once per push to main to once per PR: with 41 commits landing in a single
day, consolidating those into one PR is a reduction in real-Obsidian runs.

3. CLAUDE.md's honour-system rule becomes enforceable. There are zero
protected branches
on this repository. "Never commit to main directly" is
currently a request, and CLAUDE.md records a session that broke it — three
commits of in-progress work straight onto the deployment trigger, then a
completion report that never checked CI. That document calls the outcome "luck
rather than method." Branch protection turns it into an impossibility.

What has to change

Setting Now Becomes
DefaultMergeStyle merge fast-forward-only (AllowFastForwardOnly is already true) — keeps history linear
Branch protection on main none require PR + green gate
ci.yml on: push: all branches, pull_request: push: branches:[main], pull_request:
e2e / promote both ref == main e2e on PR and main; promote main only

One thing must be proven before anything depends on it. Of the 15 pull
requests this repository has ever had — all Renovate — not one has been
merged
; every one was closed. The review path is exercised, the merge path
has never run here. Specifically unknown: whether Forgejo 16.0.5 records
merged_commit_id under fast-forward-only merging, which the changelog needs
to map a PR to its commits. If it does not, fall back to a Closes: #NN trailer
or to rebase-merge.

The changelog on top

Capture — a release note on the pull request. Kubernetes' mechanism, which
Forgejo's own tool also reads. A ## Changelog block in the PR body, or NONE
where there is no user-facing change. A PR carrying several distinct changes uses
a release-notes/<PR>.md file in the branch, one line each —
release-notes-assistant's convention, adopted verbatim so that door stays open.

Enforced at the PR, not at release, the way Kubernetes uses
do-not-merge/release-note-label-needed. Nothing merges undocumented, so the
changelog cannot silently omit a change.

Routing needs no new taxonomy. area:plugin, area:server, area:identity,
area:docs and area:ops already exist and already decide which ledger an entry
joins.

Cut. scripts/changelog.mjs draft --product plugin --since v0.1.5 reads
merged PRs whose merge commit is an ancestor of HEAD and not of v0.1.5,
filters by area:*, and takes release-notes/<PR>.md, else the ## Changelog
block, else the PR title — into ## Unreleased in docs/changelog-plugin.md,
every line carrying its PR number. It also reports orphans: commits in range
reachable from no counted PR.

Verified readable without the broken API — tea api 404s on everything here,
including tea api get user, but tea pulls ls --fields index,title,body,labels --output json returns full bodies.

Review, then derive. The draft is a file in the worktree; you edit it like
any other file. That is the review window and it needs no bespoke UI. cut
stamps the heading, and one ledger then derives three published copies: the
mirror's CHANGELOG.md, the GitHub release notes, and /changelog on the site.

The LLM is a checker, not an author. The documented risk is that it "can both
omit changes and add ones that don't exist, both breaking the changelog as a
source of truth." A checker cannot invent an entry, and a false positive costs a
glance.

The server

CD does not change. promote keeps moving :stable on every green main.
A server version is a tag on a sha already in production — a name for a point
in time, not a gate in front of one.

State plainly, because it is the opposite of the plugin: the server changelog
is retrospective.
Its ## Unreleased means "live but unnamed", not "coming
soon", and the published page must not imply otherwise.

Two ledgers rather than one is forced, not chosen: build-mirror.mjs's
FORBIDDEN scan rejects any staged file containing packages/server,
plan-phase or positioning.md, so a combined changelog could not be published
at all.

Costs and objections

  1. The merge path is unproven here. 15 PRs, 0 merged. Prove it first.
  2. It adds a step to every plan. Small — the branch is already pushed;
    tea pulls create replaces the merge. It buys the gate running before main.
  3. Concurrent sessions hit conflicts at the PR instead of the merge. That is
    where they should be hit.
  4. Renovate PRs would need merging rather than closing. All 15 were closed
    and applied some other way; under branch protection that path disappears.
  5. release-notes-assistant is not adopted outright, despite being Forgejo's
    own tool: it produces one output, defaults to conventional-commit
    categorisation we do not use, and has no notion of two products in one
    repository. Its two best ideas are taken. Revisit once the workflow is proven.

Children

In dependency order. #78 blocks everything else — nothing should be built on
a merge path that has never run here.

  • #78 — Prove the pull-request merge path before anything depends on it
  • #79 — Switch the merge style to fast-forward-only and protect main
  • #80 — ci.yml: narrow push to main, move the e2e gate onto the pull request
  • #81 — Document the workflow, and require a release note to merge
  • #82 — scripts/changelog.mjs, the two ledgers, and the 0.1.0-0.1.5 backfill
  • #83 — Derive CHANGELOG.md into the published mirror
  • #84 — Derive the GitHub release notes, and show them at the dry-run checkpoint
  • #85 — Derive /changelog onto nectenda.com
  • #86 — Give the server a version and namespaced tags, leaving CD unchanged

#82 also deletes docs/releasing.md:134's false claim that a changelog line
already exists.

## Why Six plugin versions have shipped to a public directory and **a user cannot find out what changed in any of them.** `docs/releasing.md:134` says "Every plugin release bumps `manifest.json` with a changelog line"; `scripts/bump-plugin-version.mjs` touches `manifest.json`, `versions.json` and `package.json` and writes no changelog. The claim is false, in the same way the README's install section was. Designing the fix surfaced the real gap. Every automated changelog worth copying is built on a **unit of change that carries prose** — Kubernetes on the pull request, GitLab on the commit, Forgejo's own `release-notes-assistant` on the pull request. This repository appeared to have none, only a stream of commits. It does have one, unrecognised: **the worktree.** One plan, one worktree, one branch. Four are on `origin` as this is written. Each is a coherent story with a beginning and an end. It is already the unit of change — it is simply not addressable. ## The decision **One plan → one worktree → one branch → one pull request → fast-forward onto `main`.** The workflow in `CLAUDE.md` does not change shape. It gains a merge event. ## What this fixes beyond the changelog **1. The gate moves in front of the merge.** `e2e` and `promote` are both `if: github.ref == 'refs/heads/main'` today. The comment gives the reasoning — "the two minutes of real Obsidian are spent on what is about to be promoted." Under pull requests *the merge* is what is about to be promoted, so the same reasoning puts the gate on the PR, where failure means "do not merge" rather than "`main` is already broken and the deploy has been stopped after the fact." **2. It costs less CI, not more — measured.** Every PR currently triggers CI **twice**: `push` and `pull_request` fire on the same sha, confirmed on eight distinct shas; the repo has 317 `push` runs against 15 `pull_request` runs. Narrowing `push:` to `main` removes the duplicate. A branch with no PR then gets no CI, which is deliberate — opening a PR is how you ask for it. And `e2e` moves from once per push to `main` to once per PR: with 41 commits landing in a single day, consolidating those into one PR is a **reduction** in real-Obsidian runs. **3. `CLAUDE.md`'s honour-system rule becomes enforceable.** There are **zero protected branches** on this repository. "Never commit to `main` directly" is currently a request, and `CLAUDE.md` records a session that broke it — three commits of in-progress work straight onto the deployment trigger, then a completion report that never checked CI. That document calls the outcome "luck rather than method." Branch protection turns it into an impossibility. ## What has to change | Setting | Now | Becomes | |---|---|---| | `DefaultMergeStyle` | `merge` | `fast-forward-only` (**`AllowFastForwardOnly` is already true**) — keeps history linear | | Branch protection on `main` | none | require PR + green gate | | `ci.yml` `on:` | `push:` all branches, `pull_request:` | `push: branches:[main]`, `pull_request:` | | `e2e` / `promote` | both `ref == main` | `e2e` on PR **and** main; `promote` main only | **One thing must be proven before anything depends on it.** Of the 15 pull requests this repository has ever had — all Renovate — **not one has been merged**; every one was closed. The review path is exercised, the *merge* path has never run here. Specifically unknown: whether Forgejo 16.0.5 records `merged_commit_id` under **fast-forward-only** merging, which the changelog needs to map a PR to its commits. If it does not, fall back to a `Closes: #NN` trailer or to rebase-merge. ## The changelog on top **Capture — a release note on the pull request.** Kubernetes' mechanism, which Forgejo's own tool also reads. A `## Changelog` block in the PR body, or `NONE` where there is no user-facing change. A PR carrying several distinct changes uses a `release-notes/<PR>.md` file in the branch, one line each — `release-notes-assistant`'s convention, adopted verbatim so that door stays open. **Enforced at the PR, not at release**, the way Kubernetes uses `do-not-merge/release-note-label-needed`. Nothing merges undocumented, so the changelog cannot silently omit a change. **Routing needs no new taxonomy.** `area:plugin`, `area:server`, `area:identity`, `area:docs` and `area:ops` already exist and already decide which ledger an entry joins. **Cut.** `scripts/changelog.mjs draft --product plugin --since v0.1.5` reads merged PRs whose merge commit is an ancestor of `HEAD` and not of `v0.1.5`, filters by `area:*`, and takes `release-notes/<PR>.md`, else the `## Changelog` block, else the PR title — into `## Unreleased` in `docs/changelog-plugin.md`, every line carrying its PR number. It also reports **orphans**: commits in range reachable from no counted PR. Verified readable without the broken API — `tea api` 404s on everything here, including `tea api get user`, but `tea pulls ls --fields index,title,body,labels --output json` returns full bodies. **Review, then derive.** The draft is a file in the worktree; you edit it like any other file. That is the review window and it needs no bespoke UI. `cut` stamps the heading, and one ledger then derives three published copies: the mirror's `CHANGELOG.md`, the GitHub release notes, and `/changelog` on the site. **The LLM is a checker, not an author.** The documented risk is that it "can both omit changes and add ones that don't exist, both breaking the changelog as a source of truth." A checker cannot invent an entry, and a false positive costs a glance. ## The server **CD does not change.** `promote` keeps moving `:stable` on every green `main`. A server version is a **tag on a sha already in production** — a name for a point in time, not a gate in front of one. State plainly, because it is the opposite of the plugin: **the server changelog is retrospective.** Its `## Unreleased` means "live but unnamed", not "coming soon", and the published page must not imply otherwise. Two ledgers rather than one is forced, not chosen: `build-mirror.mjs`'s `FORBIDDEN` scan rejects any staged file containing `packages/server`, `plan-phase` or `positioning.md`, so a combined changelog could not be published at all. ## Costs and objections 1. **The merge path is unproven here.** 15 PRs, 0 merged. Prove it first. 2. **It adds a step to every plan.** Small — the branch is already pushed; `tea pulls create` replaces the merge. It buys the gate running before `main`. 3. **Concurrent sessions hit conflicts at the PR instead of the merge.** That is where they should be hit. 4. **Renovate PRs would need merging rather than closing.** All 15 were closed and applied some other way; under branch protection that path disappears. 5. **`release-notes-assistant` is not adopted outright**, despite being Forgejo's own tool: it produces one output, defaults to conventional-commit categorisation we do not use, and has no notion of two products in one repository. Its two best ideas are taken. Revisit once the workflow is proven. ## Children In dependency order. **#78 blocks everything else** — nothing should be built on a merge path that has never run here. - #78 — Prove the pull-request merge path before anything depends on it - #79 — Switch the merge style to fast-forward-only and protect `main` - #80 — `ci.yml`: narrow push to main, move the e2e gate onto the pull request - #81 — Document the workflow, and require a release note to merge - #82 — `scripts/changelog.mjs`, the two ledgers, and the 0.1.0-0.1.5 backfill - #83 — Derive `CHANGELOG.md` into the published mirror - #84 — Derive the GitHub release notes, and show them at the dry-run checkpoint - #85 — Derive `/changelog` onto nectenda.com - #86 — Give the server a version and namespaced tags, leaving CD unchanged #82 also deletes `docs/releasing.md:134`'s false claim that a changelog line already exists.
Author
Owner

Done on 23 September 2026. Every child is closed, and all of the code is on main with CI green and deployed: 5f5a0db was promoted and eu1 serves it.

The workflow

  • #78: the merge path is proven. A throwaway PR into a scratch base showed that fast-forward-only records merged_commit_id (the head tip) and merge_base, and REST exposes both. No trailer fallback was needed.
  • #80: CI runs on pull requests and main only, with no more double runs. e2e is the gate on the PR and runs again on main for promote. Since #121, e2e starts alongside build, which cut a PR gate from about 10 min to about 5½.
  • #81: CLAUDE.md now carries the PR flow. There is a PR template, and a release-note check that fails an untouched template.
  • #79: main is protected: no direct pushes (admins included), three required checks, outdated branches blocked, fast-forward-only by default. Renovate merges through PRs.

The changelog

  • #82: docs/changelog-plugin.md and docs/changelog-server.md are the ledgers. scripts/changelog.mjs draft|cut works from merged PRs, by commit graph and never by date, and reports orphans after WORKFLOW_EPOCH = ea78644. The anchor tags plugin-v0.1.0…0.1.5 and server-v1.0.0 are pushed.
  • #83: the mirror now ships a generated CHANGELOG.md.
  • #84: GitHub release notes lead with the ledger section, a release with no section is refused, and the dry run prints the notes. --release tags plugin-v<ver> here.
  • #85: https://nectenda.com/changelog renders from the ledger. That was checked live.
  • #86: server and identity are 1.0.0, and the ledger is retrospective. CD is unchanged.

Found and fixed along the way

  • Overlapping e2e runs collided on fixed ports once e2e ran on PRs, because the job-level concurrency was never enforced. They are now isolated per run (#118): a port block with a sentinel, a per-checkout TMPDIR, a scoped clean.sh, and Xvfb -nolisten local. That was proven 3-wide in CI and 2-wide on a Mac. The Auto Feature Builder's plan counted this as its phase 5.
  • Runner capacity went 2 → 5, on measurements. #126 adds scripts/ci-capacity.mjs and a clconsole load monitor (a PSI timer). The monitor only logs until a Kuma push URL is configured.
  • Tag pushes run the tagged commit's workflows, and paths:-only filters fire on them. e2e.yml now excludes tags.

Open, and not part of this

  • #119: a file the owner created while a guest's listing was opening never reached the guest. Seen once. It is recorded in docs/sync-limitations.md.
  • Kuma alerting for the clconsole monitor needs a push monitor created, with credentials this session did not have.
  • draft --assist (LLM-suggested prose) is deferred, as the issue allowed.
Done on 23 September 2026. Every child is closed, and all of the code is on `main` with CI green and deployed: `5f5a0db` was promoted and `eu1` serves it. ## The workflow - **#78**: the merge path is proven. A throwaway PR into a scratch base showed that fast-forward-only records `merged_commit_id` (the head tip) and `merge_base`, and REST exposes both. No trailer fallback was needed. - **#80**: CI runs on pull requests and `main` only, with no more double runs. e2e is the gate **on the PR** and runs again on `main` for promote. Since #121, e2e starts alongside build, which cut a PR gate from about 10 min to about 5½. - **#81**: CLAUDE.md now carries the PR flow. There is a PR template, and a `release-note` check that fails an untouched template. - **#79**: `main` is protected: no direct pushes (admins included), three required checks, outdated branches blocked, fast-forward-only by default. Renovate merges through PRs. ## The changelog - **#82**: `docs/changelog-plugin.md` and `docs/changelog-server.md` are the ledgers. `scripts/changelog.mjs draft|cut` works from merged PRs, by commit graph and never by date, and reports orphans after `WORKFLOW_EPOCH` = `ea78644`. The anchor tags `plugin-v0.1.0`…`0.1.5` and `server-v1.0.0` are pushed. - **#83**: the mirror now ships a generated `CHANGELOG.md`. - **#84**: GitHub release notes lead with the ledger section, a release with no section is refused, and the dry run prints the notes. `--release` tags `plugin-v<ver>` here. - **#85**: https://nectenda.com/changelog renders from the ledger. That was checked live. - **#86**: server and identity are 1.0.0, and the ledger is retrospective. CD is unchanged. ## Found and fixed along the way - **Overlapping e2e runs collided** on fixed ports once e2e ran on PRs, because the job-level `concurrency` was never enforced. They are now isolated per run (#118): a port block with a sentinel, a per-checkout TMPDIR, a scoped `clean.sh`, and Xvfb `-nolisten local`. That was proven 3-wide in CI and 2-wide on a Mac. The Auto Feature Builder's plan counted this as its phase 5. - **Runner capacity went 2 → 5**, on measurements. #126 adds `scripts/ci-capacity.mjs` and a clconsole load monitor (a PSI timer). The monitor only logs until a Kuma push URL is configured. - **Tag pushes run the tagged commit's workflows**, and `paths:`-only filters fire on them. `e2e.yml` now excludes tags. ## Open, and not part of this - **#119**: a file the owner created while a guest's listing was opening never reached the guest. Seen once. It is recorded in `docs/sync-limitations.md`. - **Kuma alerting** for the clconsole monitor needs a push monitor created, with credentials this session did not have. - **`draft --assist`** (LLM-suggested prose) is deferred, as the issue allowed.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Nectenda/nectenda#77
No description provided.