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..45d0d29 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,135 @@ +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]*' + +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 + 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: | + 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 + path: dist/ + if-no-files-found: error + + publish: + needs: build + 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 + 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..985dd3d 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,42 @@ 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 + +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, add the environment `pypi` with required reviewers. + ## Test samples All files in `tests/samples/` were made for this package; do not add 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/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: diff --git a/pyproject.toml b/pyproject.toml index 891d993..914140f 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", @@ -227,5 +227,17 @@ 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"), and check-manifest ignores .github/ itself. +# The Java source is in the git repository only. +ignore = [ + ".editorconfig", + "java/**", + "news/**", +] + [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():