From d74200847d0d10bb720aa73b5320f84947ca97ea Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 9 Aug 2026 17:51:35 +0200 Subject: [PATCH] docs: keep a changelog, and put it above the generated release notes A release body is git-cliff over the commit subjects, which answers which pull requests went in. The consumers - the apps, the bindings, and the packages on conan, maven, PyPI, npm and SwiftPM - ask a different question before taking a bump: does the API break, what renders better, what changed under them. Droid already narrates our changes for its own users by reading that commit list; this is that work done once, at the source. CHANGELOG.md is backfilled to v5.3.0, high level but written for a developer: `File::from_memory` and `HtmlViewportMode` are named where naming them is the point, and the PDF renderer is four bullets rather than sixty pull requests. `release.py notes` now writes the section for the version, a rule, then the generated list as before, and `release.py changelog` prints one section and fails when there is none - checked in the `version` job, so release copy nobody wrote costs seconds rather than the apple build. `docs(changelog)` joins `chore(release)` in cliff.toml's skip list: the heading cut is a version number moving and would otherwise narrate itself in its own release. AGENTS.md carries the flow, and records that the next version cannot be derived from main - since the release train the tags sit on `chore(release)` commits only `releases` has, so main's nearest tag is v6.1.0 and drifting. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VFcWjZhdxeWLE5Abg4qUex --- .github/cliff.toml | 2 + .github/workflows/release.yml | 11 ++- AGENTS.md | 33 ++++++-- CHANGELOG.md | 140 ++++++++++++++++++++++++++++++++++ README.md | 3 +- scripts/release.py | 61 ++++++++++++++- 6 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/cliff.toml b/.github/cliff.toml index aa2f82039..1d0ad7dc0 100644 --- a/.github/cliff.toml +++ b/.github/cliff.toml @@ -31,6 +31,8 @@ sort_commits = "oldest" commit_parsers = [ # the stamp commit, which would otherwise land in the next release's notes { message = "^chore\\(release\\)", skip = true }, + # the changelog heading cut, which is a version number moving and nothing else + { message = "^docs\\(changelog\\)", skip = true }, { message = "^feat", group = "Features" }, { message = "^fix", group = "Fixes" }, { message = "^perf", group = "Performance" }, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b07a77059..7b42a53a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: release -# Merge main into `releases`, push, publish the draft this leaves behind. +# Cut the `## Unreleased` heading in CHANGELOG.md to the version being released, +# merge main into `releases`, push, publish the draft this leaves behind. # # To patch an older line, branch off the *tag* — `git branch release/v6.1.X # v6.1.0` — not off `releases`: the version is derived against the nearest @@ -61,6 +62,14 @@ jobs: echo "cutting $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Here rather than beside `notes`, so release copy nobody wrote costs + # seconds instead of a version. Dry runs included — a dry run is the + # rehearsal for the release body. + - name: check the changelog names this version + env: + VERSION: ${{ steps.derive.outputs.version }} + run: scripts/release.py changelog --version "$VERSION" > /dev/null + # SwiftPM resolves `Package.swift` at the tag, and its binary target names a # sha256 of an archive that does not exist until it is built — so the archive # is built here, against the version derived above, and its checksum comes diff --git a/AGENTS.md b/AGENTS.md index 3dfaae755..12219a033 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,26 +103,44 @@ cmake --build cmake-build-relwithdebinfo --target translate # CLI: file → HTM ## Releasing -Merge main into `releases`, push, publish the draft that appears — -`.github/workflows/release.yml` and `scripts/release.py`. +Cut the changelog heading, merge main into `releases`, push, publish the draft +that appears — `.github/workflows/release.yml` and `scripts/release.py`. -- **`main` carries no version.** No file is bumped, no changelog committed; a - build records `GIT_HEAD_SHA1` and a dirty flag and nothing else. The version +- **`main` carries no version in anything the build reads.** No file is bumped; + a build records `GIT_HEAD_SHA1` and a dirty flag and nothing else. The version is derived from the commit subjects (`git cliff --bumped-version`), so writing them properly is load-bearing. +- **`CHANGELOG.md` is the exception, and only as prose.** A change a consumer + would notice gets an entry under `## Unreleased` in the pull request that + makes it. Cut that heading to the version — `docs(changelog): cut vX.Y.Z` — as + the last commit on main before the merge, so both branches carry the identical + cut and the merge stays clean. The run refuses a version with no section, and + puts that section above the generated commit list in the release body. +- **Which version that is cannot be derived from main.** Since the release + train, tags sit on `chore(release)` commits that only `releases` carries, so + main's nearest reachable tag is v6.1.0 and drifts further every release — + `scripts/release.py version` only answers correctly where it runs, on + `releases` after the merge. Take the last release (`gh release list -L 1`) and + bump the minor if a `feat:` landed since, the patch otherwise. Guess wrong and + the changelog check refuses in the first job, before anything is built; re-cut + the heading, or dispatch with `--version`. - **`releases` is the mainline train**; its first-parent history is the release history. To patch an older line, branch off the *tag* (`git branch release/v6.1.X v6.1.0`) — the version is derived against the nearest *reachable* tag. That is also the trap: a `feat:` there bumps the minor to a number the mainline may already have shipped, so `release.py version` refuses - a version that is already tagged. Pass `--version` when you mean it. + a version that is already tagged. Pass `--version` when you mean it. Such a + branch also keeps its own changelog: it covers what that branch contains, so + it neither knows about later mainline versions nor sends its sections back to + main — a cherry-pick brings the entry with it, and the mainline entry for the + same fix can mention the backport. - **Release branches run `release.yml` only**; every other workflow carries `branches-ignore: ['releases', 'release/**']`. - **The release is drafted, and a human publishes it.** GitHub creates the tag only then, which is what lets it point at a commit made during the run. It also has to be a human: a release created by `GITHUB_TOKEN` raises no `release: published`, and that event starts conan, maven and android. -- **`release.yml` is the only place that writes a version anywhere**, and +- **`release.yml` is the only place that writes a version into the build**, and `release.py stamp` commits it as `chore(release): vX.Y.Z`. Today that is `Package.swift`: SwiftPM resolves it at the tag, and its binary target names the sha256 of an archive that does not exist until the release builds it. Off @@ -177,7 +195,8 @@ Merge main into `releases`, push, publish the draft that appears — - **Doc-comment markers**: `///` for functions/classes/structs/enums; trailing `///<` for the short note on the same line (enumerator/member). Keep terse. - **Pull requests**: put the `🤖 Generated with [Claude Code](https://claude.com/claude-code)` - line **at the top** of the PR body. + line **at the top** of the PR body. If the change is one a consumer would + notice, add its `CHANGELOG.md` entry in the same PR — see *Releasing*. ## Adding / extending a document format diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..649010e1d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,140 @@ +# Changelog + +What changed in the engine, for whoever builds against it — the apps, the +bindings, and the packages on conan, maven, PyPI, npm and SwiftPM. Each +[release](https://github.com/opendocument-app/OpenDocument.core/releases) puts +its section on top of the generated per-pull-request list. Back to v5.3.0; +before that the releases are all there is. + +An entry goes under `Unreleased` in the pull request that makes the change, and +only for what a consumer notices: API, formats, rendering, behaviour, packaging. +Refactoring, tests and CI are in the generated list already. A breaking change +says **Breaking** first. + +Cutting the heading to the version is the last commit on `main` before the merge +into `releases`, and the run refuses a version with no section here. A +maintenance branch keeps its own history — sections written there are not merged +back. + +## Unreleased + +- csv opens as a spreadsheet, its dialect probed unless the caller gives one, + and a value that only looks numeric left as text. +- Text encodings are named and decoded, so a document that is not UTF-8 comes + out as text rather than mojibake. +- An archive's entries are served as files instead of one page of base64. +- A docx renders on the page it was written for, and a numbered list carries its + markers as text a copy keeps. +- Sheets render in a quieter grid, under a ruler that stays put while scrolling. + +## v6.3.0 - 2026-08-08 + +- The engine runs in the browser. A WebAssembly build published as + `@opendocument/odr-core` renders documents with no server. +- A file can be opened from memory — `File::from_memory` — and every entry point + that took a path now also takes a `File`. +- `translate` no longer needs a cache path. The overloads taking one remain and + ignore it; nothing on the render path writes to disk. +- svg, ico, jxl, jp2, psd, wmf and emf are detected and translated as images. +- Embedded PDF fonts no longer render as tofu or as the wrong glyphs. +- Two translates can run at once. The unit registry and the logger clock were + not thread-safe, and the HTTP server serves from a thread pool. +- argon2id is implemented in-tree, which drops the `conan-odr-index` remote: + every dependency now comes from ConanCenter. + +## v6.2.0 - 2026-08-02 + +- Objective-C bindings and a Swift package, with the `OdrCoreObjC.xcframework` + attached to each release. +- The renderer's css and js are written into the html, so a consumer no longer + ships `odr.js` or points the library at a data directory. +- Audio and video play in the browser, and the media formats the engine could + only name are now opened. +- `mimetype` is answered without libmagic, and the libmagic option is + deprecated. +- The html viewport config is bound for Java and Python. +- A release reports where it landed, so one that reached only some of the + package registries is visible instead of silent. + +## v6.1.0 - 2026-07-30 + +- The supported-format tables are public, with a per-file-type capability query, + so a caller can ask what the engine does with a type before holding a file. +- The Java library and a new Android AAR are published to Maven Central, the + AAR tested on a device. +- `HttpServer::stop()` and destruction wait for `listen()` to return. +- A JNI wrapper stays reachable while its handle is in a native call. + +## v6.0.1 - 2026-07-27 + +- The logger thread attaches to the JVM on Android. + +## v6.0.0 - 2026-07-27 + +**Breaking.** A cleanup of the public API, source-incompatible throughout. + +- The deprecated API and the pdf2htmlEX / wvWare backends are gone, and with + them `DecoderEngine` and the whole decoder-selection dimension — with the + external backends dropped there was one engine left to select. +- `FileMeta` absorbs `DocumentMeta`, `Logger` is a value type with a public + `ILogger` sink, and `HttpServer::listen` splits into `bind` and `listen`. +- Shape geometry is typed: `Frame`, `Rect`, `Line`, `Circle` and `CustomShape` + return `Measure`, not `std::string`. `Measure::to_string` keeps 7 significant + digits instead of 4, so drawing coordinates stop being rounded away. +- Every exception derives from `odr::Exception`; `Color` uses `from_rgb` / + `from_argb`; `const char *` is gone from the public headers in favour of + `std::string_view`. +- Two long-standing defects: `ElementIterator::operator++(int)` could not + advance, and elements compared equal across unrelated documents. +- The python wheel ships the libmagic database inside it. + +## v5.7.1 - 2026-07-26 + +- The Java API loads on Android API 26 again. +- Conan consumers can turn the cli off. + +## v5.7.0 - 2026-07-26 + +- Python bindings (`pyodr`) live here rather than in `OpenDocument.py`. +- JNI bindings with an `app.opendocument.core` Java API, published to GitHub + Packages. + +## v5.6.0 - 2026-07-25 + +- Initial zoom is configurable through `HtmlViewportMode`, and fixed-size + content — PDF and images — opens fit-to-width on mobile. + +## v5.5.0 - 2026-07-25 + +- Legacy Microsoft formats gain formatting: character formatting in doc and ppt, + cell fonts and fills in xls, and pictures, slide size and slide names in ppt. +- OOXML gains pptx slide size and tables, xlsx merged cells and value types, and + docx table merges; ODF gains sub/superscript, percent line-height and + first-line indent. +- PDF exposes per-page html views, and a page range limits any document. + +## v5.4.1 - 2026-07-11 + +- **Relicensed from GPL-3.0 to MPL-2.0.** +- The HTTP server is optional (`ODR_WITH_HTTP_SERVER`), and asset bundling + defaults to off. + +## v5.4.0 - 2026-07-05 + +- **PDF is rendered by this engine.** Text is placed by its baseline, embedded + TrueType, CFF, Type1 and Type3 fonts are used and missing ones substituted + against standard-14 metrics, and images, paths, clipping, shadings, tiling + patterns, transparency and blend modes are drawn. Links become `` overlays. +- Encrypted PDFs open with the standard security handler, damaged + cross-reference tables are recovered, and xref streams, object streams and + hybrid files are read. +- Text extraction handles ToUnicode CMaps, composite fonts and the legacy CJK + CMaps, so older documents no longer come out garbled. +- Initial ppt and xls support, read in-tree. + +## v5.3.0 - 2026-06-06 + +Mostly build and CI. + +- Plain-text output honours `HtmlConfig::editable`. +- CMake options are prefixed `ODR_`. diff --git a/README.md b/README.md index 4d33ce0bf..b796950be 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,8 @@ As an alternative to the Conan remote you can also export the package locally vi ## Version -Versions and history are tracked on [GitHub](https://github.com/opendocument-app/OpenDocument.core). +What changed per version is in [`CHANGELOG.md`](CHANGELOG.md), and in full in the +[releases](https://github.com/opendocument-app/OpenDocument.core/releases). ## Testing diff --git a/scripts/release.py b/scripts/release.py index ae5fbd4db..18d03b2cd 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -9,7 +9,8 @@ Driven by `.github/workflows/release.yml`: version what the commits since the last reachable tag say comes next - notes the release body, from the same commits + changelog the CHANGELOG.md section for a version, and nothing if there is none + notes the release body: that section above the generated commit list stamp commit whatever the workflow wrote into the tree, if anything publish create or update the draft release, targeting HEAD, with assets @@ -19,6 +20,7 @@ Runnable by hand; `--dry-run` mutates nothing: scripts/release.py version + scripts/release.py changelog --version v6.2.0 scripts/release.py notes --version v6.2.0 --output /tmp/notes.md scripts/release.py publish --version v6.2.0 --notes /tmp/notes.md --dry-run """ @@ -27,12 +29,17 @@ import argparse import os +import re import subprocess import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent CLIFF_CONFIG = REPO_ROOT / ".github" / "cliff.toml" +CHANGELOG = REPO_ROOT / "CHANGELOG.md" + +# `## v6.3.0 - 2026-08-08`, or `## Unreleased`; the date is decoration +VERSION_HEADING = re.compile(r"^## +(\S+)") # skipped by `cliff.toml`, so it stays out of the next release's notes STAMP_SUBJECT = "chore(release): {version}" @@ -96,9 +103,52 @@ def command_version(arguments: argparse.Namespace) -> None: print(version) +def changelog_section(version: str) -> str: + """The body under `## ` in CHANGELOG.md. + + Read before anything is built, so release copy nobody wrote costs seconds + rather than a version. Being read is what keeps the changelog from rotting. + """ + wanted = version.strip().removeprefix("v") + + found = False + body: list[str] = [] + for line in CHANGELOG.read_text().splitlines(): + heading = VERSION_HEADING.match(line) + if heading: + if found: + break + found = heading.group(1).removeprefix("v") == wanted + continue + if found: + body.append(line) + + if not found: + raise SystemExit( + f"CHANGELOG.md has no section for {version}. Cut the `## Unreleased` " + f"heading to `## {version} - ` on main before merging into " + f"`releases` — that copy is the top of the release body." + ) + + text = "\n".join(body).strip("\n") + if not text: + raise SystemExit( + f"the `## {version}` section of CHANGELOG.md is empty. A release " + f"with nothing worth telling a consumer should say so, not nothing." + ) + return text + + +def command_changelog(arguments: argparse.Namespace) -> None: + print(changelog_section(arguments.version)) + + def command_notes(arguments: argparse.Namespace) -> None: - cliff("--tag", arguments.version, "--latest", "--unreleased", - "-o", str(arguments.output)) + """The hand-written section, then the commits it summarises.""" + summary = changelog_section(arguments.version) + generated = cliff("--tag", arguments.version, "--latest", "--unreleased", + capture=True) + arguments.output.write_text(f"{summary}\n\n---\n\n{generated}\n") def command_stamp(arguments: argparse.Namespace) -> None: @@ -174,6 +224,11 @@ def main() -> None: version.add_argument("--version", help="override what the commits say") version.set_defaults(function=command_version) + changelog = subparsers.add_parser("changelog", + help="print the CHANGELOG.md section") + changelog.add_argument("--version", required=True) + changelog.set_defaults(function=command_changelog) + notes = subparsers.add_parser("notes", help="write the release body") notes.add_argument("--version", required=True) notes.add_argument("--output", type=Path, required=True)