From 58b8941767d34bb389e704f93cbc6d9e84530fa6 Mon Sep 17 00:00:00 2001 From: Manabu TERADA Date: Tue, 20 Oct 2026 18:39:52 +0900 Subject: [PATCH 1/6] Mark the package as beta The first release of 2.0 goes out as 2.0.0b1, so the classifier says "4 - Beta" instead of "5 - Production/Stable". Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 891d993..0ccf521 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ keywords = [ "search", ] classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: Plone", "Framework :: Plone :: 6.0", From f470bc9031f2bf4206c31a2a1662723ca8b0f64e Mon Sep 17 00:00:00 2001 From: Manabu TERADA Date: Tue, 20 Oct 2026 18:48:26 +0900 Subject: [PATCH 2/6] Keep the tests and AGENTS.md out of the sdist, and set up check-manifest zest.releaser runs check-manifest in prerelease, and it failed: the Java source, the news fragments, .editorconfig and the test samples are in git but not in the sdist. setuptools also put tests/test_*.py into the sdist, without conftest.py and the samples, so those tests could not run, and "include *.md" took AGENTS.md along. MANIFEST.in now prunes tests/ and excludes AGENTS.md, so the sdist has the package, the README, the changelog and the licenses, as before. [tool.check-manifest] ignores the rest. RED: check-manifest exited 1 and suggested "recursive-include java ...", "news ..." and "tests ...". GREEN: "lists of files in version control and sdist match". Mutations: dropping ".editorconfig", "java/**" or "news/**" from the ignore list makes check-manifest fail; without "prune tests" the sdist has 6 files of tests/ again. Co-Authored-By: Claude Opus 5.5 --- MANIFEST.in | 2 ++ pyproject.toml | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 09b5ed8..a7324cc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,10 @@ graft src/c2 include *.md include pyproject.toml +exclude AGENTS.md exclude tox.ini exclude *.txt +prune tests global-exclude *.pyc include LICENSE.GPL include LICENSE.txt diff --git a/pyproject.toml b/pyproject.toml index 0ccf521..019f350 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,5 +227,15 @@ source_pkgs = ["c2.transform.msoffice", "tests"] branch = true parallel = true +[tool.check-manifest] +# Files in git that are not in the sdist. The sdist has the package, the +# README, the changelog and the licenses only; MANIFEST.in leaves out the +# rest (tests/ with "prune"). The Java source is in the git repository. +ignore = [ + ".editorconfig", + "java/**", + "news/**", +] + [tool.zest-releaser] python-file-with-version = "src/c2/transform/msoffice/__init__.py" From 43a9ffd47bcefdd7be09373a29ef9d90956f33c1 Mon Sep 17 00:00:00 2001 From: Manabu TERADA Date: Tue, 20 Oct 2026 18:55:06 +0900 Subject: [PATCH 3/6] Keep the version only in pyproject.toml zest.releaser 9.9.1 changes only python-file-with-version when it is set, so prerelease left "version" in pyproject.toml at 2.0.0.dev0: the tag 2.0.0b1 would have built 2.0.0.dev0 distributions, and test_version, which compared __version__ with the literal "2.0.0.dev0", would have failed at the tag. python-file-with-version is removed, so zest.releaser changes pyproject.toml, and __version__ comes from the installed metadata. A missing distribution raises PackageNotFoundError: the add-on cannot be used without being installed, and z3c.autoinclude finds it by its metadata too. [tool.zest-releaser] also gets release = false: fullrelease tags, and a GitHub Actions workflow uploads to PyPI (next commit). RED (throwaway clone, before): prerelease to 2.0.0b1 changed CHANGELOG.md, news/ and __init__.py; pyproject.toml stayed at 2.0.0.dev0. GREEN (throwaway clone, after): prerelease set pyproject.toml to 2.0.0b1, release made the tag without uploading, postrelease set 2.0.0b2.dev0; the tag builds 2.0.0b1 distributions whose __version__ is 2.0.0b1. Mutation: __version__ = "2.0.0b1" (a literal) fails test_version_is_the_one_of_the_distribution ('2.0.0b1' == '2.0.0.dev0'). Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 3 ++- src/c2/transform/msoffice/__init__.py | 7 +++++-- tests/test_imports.py | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 019f350..4f33c1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -238,4 +238,5 @@ ignore = [ ] [tool.zest-releaser] -python-file-with-version = "src/c2/transform/msoffice/__init__.py" +# fullrelease makes the tag; the release workflow uploads to PyPI. +release = false diff --git a/src/c2/transform/msoffice/__init__.py b/src/c2/transform/msoffice/__init__.py index dd91d5d..4c711d8 100644 --- a/src/c2/transform/msoffice/__init__.py +++ b/src/c2/transform/msoffice/__init__.py @@ -3,11 +3,14 @@ # Importing config registers the Office types with the mimetypes module. from c2.transform.msoffice import config # noqa: F401 -import logging +from importlib.metadata import version +import logging -__version__ = "2.0.0.dev0" PACKAGE_NAME = "c2.transform.msoffice" +# The version is kept only in pyproject.toml, where zest.releaser changes it. +__version__ = version(PACKAGE_NAME) + logger = logging.getLogger(PACKAGE_NAME) diff --git a/tests/test_imports.py b/tests/test_imports.py index 962975d..1928a0c 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -1,7 +1,8 @@ -def test_version(): +def test_version_is_the_one_of_the_distribution(): from c2.transform.msoffice import __version__ + from importlib.metadata import version - assert __version__ == "2.0.0.dev0" + assert __version__ == version("c2.transform.msoffice") def test_c2_is_a_pep420_namespace(): From 10c915a0724a1821dab1580f1d560111e5d57380 Mon Sep 17 00:00:00 2001 From: Manabu TERADA Date: Tue, 20 Oct 2026 19:02:47 +0900 Subject: [PATCH 4/6] Publish releases from GitHub Actions with Trusted Publishing fullrelease pushes a tag such as 2.0.0b1, and release.yml takes it from there: - test: plone-package.yml, now callable with workflow_call. - build: checks that the tag is the version in pyproject.toml, builds the sdist and the wheel with uv, runs twine check and checks that the wheel contains msdoc2txt.jar. - publish: uploads to PyPI with Trusted Publishing, in the "pypi" environment (approval), with id-token: write only. - github-release: signs the distributions with Sigstore and creates the GitHub Release with the section of the version from CHANGELOG.md and the files and signatures attached; a, b and rc versions are pre-releases. A manual run (workflow_dispatch) tests and builds only; publish and github-release run for a pushed tag only. Actions are pinned to the SHA of a release at least two weeks old, credentials are not persisted, the default permissions are contents: read, and setup-uv runs without its cache. zizmor 1.30.1 asks for the new "$/..." self-repository syntax for the reusable workflow, but actionlint 1.7.12 (the latest release) rejects it, so the finding is ignored on that line. AGENTS.md describes the release steps and the one-time setup on PyPI and GitHub. Checks: actionlint 0, zizmor --offline 0 (1 ignored). The tag check and the CHANGELOG extraction ran locally on a throwaway clone tagged 2.0.0b1 (12 entries; 1.0b6 gives its 1 entry; a version without a section gives nothing, so the step fails). Mutations: checkout without persist-credentials: false (zizmor artipacked), the publish action on @release/v1 (zizmor high), "permissions: write-all" (zizmor high), ${{ github.ref_name }} in run (zizmor template injection), "needs: biuld" (actionlint). Turning the setup-uv cache back on is not caught by either tool. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/plone-package.yml | 2 + .github/workflows/release.yml | 136 ++++++++++++++++++++++++++++ AGENTS.md | 41 ++++++++- 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/plone-package.yml b/.github/workflows/plone-package.yml index 09757d0..33bce81 100644 --- a/.github/workflows/plone-package.yml +++ b/.github/workflows/plone-package.yml @@ -10,6 +10,8 @@ on: pull_request: branches: [main] workflow_dispatch: + # release.yml runs the tests before it builds. + workflow_call: permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..36d5264 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,136 @@ +name: Release + +# Publishes to PyPI with Trusted Publishing when zest.releaser's fullrelease +# pushes a version tag such as 2.0.0b1 (no "v" prefix). +# +# Every action is pinned to a full commit SHA of a release that has been out +# for at least two weeks (cooldown). The comment after each SHA is the tag. + +on: + push: + tags: + - '[0-9]*' + # A manual run on any branch tests and builds only; publish and + # github-release run for a tag push only. + workflow_dispatch: + +permissions: + contents: read + +# Never cancel a release half way. The group must differ from the one in +# plone-package.yml, which runs inside this workflow (group names ignore case). +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + # actionlint 1.7.12 does not know the self-repository syntax ($/...) yet. + # A reusable workflow given with ./ is read from the commit of this run, + # not from the files of a runner. + uses: ./.github/workflows/plone-package.yml # zizmor: ignore[self-repository] + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: false + - name: Check that the tag is the version in pyproject.toml + if: github.ref_type == 'tag' + env: + TAG: ${{ github.ref_name }} + run: | + version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + if [ "$version" != "$TAG" ]; then + echo "::error::The tag $TAG is not the version $version in pyproject.toml" + exit 1 + fi + - name: Build the sdist and the wheel + run: uv build --out-dir dist + - name: Check the distributions + run: uvx twine@7.0.0 check --strict dist/* + - name: Check that the wheel contains the jar + run: | + unzip -l dist/*.whl | grep -E ' c2/transform/msoffice/msdoc2txt\.jar$' + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish: + needs: build + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/c2.transform.msoffice + permissions: + # Trusted Publishing + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + + github-release: + needs: publish + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + # gh release create + contents: write + # Sigstore + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + - name: Sign the distributions + uses: sigstore/gh-action-sigstore-python@790bc6befb9d733738f18d8f895854b453640ec9 # v3.5.0 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Take the notes of this version from CHANGELOG.md + env: + TAG: ${{ github.ref_name }} + run: | + # The section starts at "## ()" and ends before the + # next "## " heading. + awk -v heading="## $TAG (" ' + index($0, heading) == 1 { found = 1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md > "$RUNNER_TEMP/notes.md" + if ! grep -q '[^[:space:]]' "$RUNNER_TEMP/notes.md"; then + echo "::error::No section for $TAG in CHANGELOG.md" + exit 1 + fi + - name: Create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + # A version with a, b or rc (PEP 440) is a pre-release. + prerelease=() + if [[ "$TAG" =~ (a|b|rc)[0-9]+$ ]]; then + prerelease=(--prerelease) + fi + gh release create "$TAG" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "$TAG" \ + --notes-file "$RUNNER_TEMP/notes.md" \ + "${prerelease[@]}" diff --git a/AGENTS.md b/AGENTS.md index 83fea83..9e730e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ - `setuphandlers.py`: install and uninstall (`post_handler`s). - `msdoc2txt.jar`: the bundled Java program (Apache POI 5.5.1), built from `java/` with `java/build.sh`; do not change it by hand. -- `tests/`: pytest tests (not shipped in the wheel). +- `tests/`: pytest tests (not shipped in the wheel or the sdist). - `test_transform.py`, `test_config.py`: unit tests; `java` is replaced. - `test_setup.py`: install and uninstall in a Plone site. - `test_java.py`: runs the real jar on the files in `tests/samples/`. @@ -51,6 +51,45 @@ After a change in `java/`, run `java/build.sh` (needs Docker) and the jar with the change; CI rebuilds it and fails if it differs. If the output changes, find out why before you change a file in `java/expected/`. +## Releasing + +The version is kept only in `pyproject.toml`; `__version__` is read from +the installed metadata. Releases are made with zest.releaser and published +to PyPI by `.github/workflows/release.yml` with Trusted Publishing; nothing +is uploaded from a local machine. + +1. On an up-to-date `main` with the news fragments of the release, run + `fullrelease` (install it with `pip install -e ".[release]"`; the + environment needs `pip`, because check-manifest builds the sdist in an + isolated environment). It runs check-manifest and pyroma, asks for the + version (for example `2.0.0b1`), builds `CHANGELOG.md` from `news/` + with towncrier, commits, tags (the tag is the version, without `v`), + sets the next development version and asks before it pushes the + commits and the tag. `release = false` in `[tool.zest-releaser]` keeps + it from uploading. +2. The pushed tag starts the release workflow: the tests of + `plone-package.yml`, then a build that checks that the tag is the + version in `pyproject.toml`, runs `twine check` and checks that the + wheel contains `msdoc2txt.jar`. +3. The `publish` job waits for an approval of the `pypi` environment in + GitHub Actions. After the approval it uploads to PyPI. +4. The `github-release` job signs the sdist and the wheel with Sigstore and + creates a GitHub Release for the tag, with the section of the version + from `CHANGELOG.md` and the files and their signatures attached. A + version with `a`, `b` or `rc` is marked as a pre-release. + +A manual run of the release workflow (workflow_dispatch) on a branch runs +the tests and the build only, without publishing. + +Set up once: + +- On PyPI, add a Trusted Publisher to the project: owner `cmscom`, + repository `c2.transform.msoffice`, workflow `release.yml`, environment + `pypi`. +- On GitHub, create the environment `pypi` with required reviewers (the + people who approve releases). Limiting its deployments to tags is + recommended. + ## Test samples All files in `tests/samples/` were made for this package; do not add From 65e5220d2a722cc272d417e3d8a887e30215ec55 Mon Sep 17 00:00:00 2001 From: Manabu TERADA Date: Tue, 20 Oct 2026 19:08:49 +0900 Subject: [PATCH 5/6] Name the Java version in the installation steps The supported versions list Java 11 or later, but the first installation step only said "Install Java"; 1.0b6 users may still have Java 8. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 526f921..cdd00fd 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ formats are passed to the jar but are not covered by the tests. ## Installation -1. Install Java (a headless JRE is enough) so that `java` is on the `PATH` of - the Zope process. +1. Install Java 11 or later (a headless JRE is enough) so that `java` is on + the `PATH` of the Zope process. 2. Add `c2.transform.msoffice` to your project's dependencies, for example with pip: From 3a8b8da59cb256b982147445f2a9b9aaf14fd0ea Mon Sep 17 00:00:00 2001 From: Manabu TERADA Date: Tue, 20 Oct 2026 19:15:54 +0900 Subject: [PATCH 6/6] Start the release workflow from a tag push only There is one way to publish: the tag that fullrelease pushes. The manual run (workflow_dispatch) is removed, and with it the "if" of publish and github-release and of the tag check, which only a tag can start now. The jar check reads the file list with "unzip -Z1" and names the missing file in an error, and the Releasing section of AGENTS.md and the check-manifest comment use the same words as c2.splitter.icu, so that the two packages release the same way. Checks: actionlint 0, zizmor --offline 0 (1 ignored), check-manifest matches. The jar check passes on the 2.0.0b1 wheel of a throwaway clone and fails, naming the jar, on that wheel with the jar removed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 13 ++++--- AGENTS.md | 65 +++++++++++++++++------------------ pyproject.toml | 3 +- 3 files changed, 39 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36d5264..45d0d29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,6 @@ on: push: tags: - '[0-9]*' - # A manual run on any branch tests and builds only; publish and - # github-release run for a tag push only. - workflow_dispatch: permissions: contents: read @@ -41,7 +38,6 @@ jobs: with: enable-cache: false - name: Check that the tag is the version in pyproject.toml - if: github.ref_type == 'tag' env: TAG: ${{ github.ref_name }} run: | @@ -56,7 +52,12 @@ jobs: run: uvx twine@7.0.0 check --strict dist/* - name: Check that the wheel contains the jar run: | - unzip -l dist/*.whl | grep -E ' c2/transform/msoffice/msdoc2txt\.jar$' + files=$(unzip -Z1 dist/*.whl) + name=c2/transform/msoffice/msdoc2txt.jar + if ! grep -qxF "$name" <<< "$files"; then + echo "::error::$name is not in the wheel" + exit 1 + fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist @@ -65,7 +66,6 @@ jobs: publish: needs: build - if: github.event_name == 'push' && github.ref_type == 'tag' runs-on: ubuntu-latest environment: name: pypi @@ -82,7 +82,6 @@ jobs: github-release: needs: publish - if: github.event_name == 'push' && github.ref_type == 'tag' runs-on: ubuntu-latest permissions: # gh release create diff --git a/AGENTS.md b/AGENTS.md index 9e730e7..985dd3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,42 +53,39 @@ output changes, find out why before you change a file in `java/expected/`. ## Releasing -The version is kept only in `pyproject.toml`; `__version__` is read from -the installed metadata. Releases are made with zest.releaser and published -to PyPI by `.github/workflows/release.yml` with Trusted Publishing; nothing -is uploaded from a local machine. - -1. On an up-to-date `main` with the news fragments of the release, run - `fullrelease` (install it with `pip install -e ".[release]"`; the - environment needs `pip`, because check-manifest builds the sdist in an - isolated environment). It runs check-manifest and pyroma, asks for the - version (for example `2.0.0b1`), builds `CHANGELOG.md` from `news/` - with towncrier, commits, tags (the tag is the version, without `v`), - sets the next development version and asks before it pushes the - commits and the tag. `release = false` in `[tool.zest-releaser]` keeps - it from uploading. -2. The pushed tag starts the release workflow: the tests of - `plone-package.yml`, then a build that checks that the tag is the - version in `pyproject.toml`, runs `twine check` and checks that the - wheel contains `msdoc2txt.jar`. -3. The `publish` job waits for an approval of the `pypi` environment in - GitHub Actions. After the approval it uploads to PyPI. -4. The `github-release` job signs the sdist and the wheel with Sigstore and - creates a GitHub Release for the tag, with the section of the version - from `CHANGELOG.md` and the files and their signatures attached. A - version with `a`, `b` or `rc` is marked as a pre-release. - -A manual run of the release workflow (workflow_dispatch) on a branch runs -the tests and the build only, without publishing. - -Set up once: - -- On PyPI, add a Trusted Publisher to the project: owner `cmscom`, +Releases are made with zest.releaser on a maintainer's machine and published +to PyPI by `.github/workflows/release.yml` with Trusted Publishing; no PyPI +token is used. The version is kept only in `pyproject.toml`. + +1. Install the tools of the `release` extra in a virtual environment that + has pip (check-manifest builds the sdist in an isolated environment made + with pip): + + ```shell + pip install "zest.releaser[recommended]" zestreleaser.towncrier zest.pocompile + ``` + + On an up-to-date `main` with a clean working tree, run `fullrelease`. + It runs check-manifest and pyroma, writes the news fragments into + `CHANGELOG.md`, sets the version, commits, makes the tag (such as + `2.0.0b1`, without `v`), sets the next development version and asks + whether to push. Answer yes: it runs `git push` and `git push --tags`. + It does not upload anything (`release = false`). +2. The tag starts the Release workflow on GitHub Actions: the tests of + `plone-package.yml`, then the build (it stops if the tag is not the + version in `pyproject.toml` or the wheel has no `msdoc2txt.jar`). +3. The `publish` job waits for a reviewer of the `pypi` environment to + approve it, then uploads the sdist and the wheel to PyPI. +4. The `github-release` job signs them with Sigstore and makes a GitHub + Release with the section of this version from `CHANGELOG.md` and the + files. Versions with `a`, `b` or `rc` are marked as pre-releases. + +Once, before the first release: + +- On PyPI, add a trusted publisher to the project: owner `cmscom`, repository `c2.transform.msoffice`, workflow `release.yml`, environment `pypi`. -- On GitHub, create the environment `pypi` with required reviewers (the - people who approve releases). Limiting its deployments to tags is - recommended. +- On GitHub, add the environment `pypi` with required reviewers. ## Test samples diff --git a/pyproject.toml b/pyproject.toml index 4f33c1d..914140f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,7 +230,8 @@ parallel = true [tool.check-manifest] # Files in git that are not in the sdist. The sdist has the package, the # README, the changelog and the licenses only; MANIFEST.in leaves out the -# rest (tests/ with "prune"). The Java source is in the git repository. +# rest (tests/ with "prune"), and check-manifest ignores .github/ itself. +# The Java source is in the git repository only. ignore = [ ".editorconfig", "java/**",