diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..01bb1652 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,184 @@ +--- +name: publish + +# Publishes a tagged release to PyPI using PyPI Trusted Publishing (OIDC), so +# no API token is stored anywhere. Replaces the long manual "build in a clean +# clone and twine upload" dance in docs/design/RELEASE-HOWTO.md. +# +# The trigger is a pushed tag. Both flavours of tag go through here: +# +# v3.3.1 - the real release, tagged on the release branch +# v3.3.1a1 - the companion niquests-free alpha, tagged on a throwaway +# branch that is never pushed (see the RELEASE-HOWTO). The +# workflow file comes from the tagged tree, so the alpha builds +# with whatever pyproject.toml that branch carries. +# +# Nothing is uploaded until the build has been checked and - if the `pypi` +# environment is configured with required reviewers - a human has clicked +# Approve. A wrong tag can therefore be rejected without burning the version +# number on PyPI. +# +# TODO (next patch release): verify that the tag carries a good signature from +# the maintainer before anything is built. See the "Verifying the tag +# signature" section of docs/design/RELEASE-HOWTO.md for the recipe. + +on: + push: + tags: + # Release and pre-release tags alike: v3.3.1, v3.3.1a1, v3.4.0rc2. + - "v[0-9]+.[0-9]+.[0-9]+*" + # Lets the whole build+check path be rehearsed on an existing tag without + # publishing anything. Use this to shake out workflow mistakes. + workflow_dispatch: + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build: + name: build and check the artifacts + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + steps: + - uses: actions/checkout@v5 + with: + # hatch-vcs derives the version from the tags. + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install tox packaging + # Same check the nightly `package` workflow runs: builds the sdist and + # the wheel and refuses anything git does not track. This is what keeps + # a stray venv/ or conf_private.py out of the tarball. + - name: Build sdist and wheel, and check what is in them + run: tox -e package + - name: Check the built version against the tag + id: version + env: + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: python tests/tools/check_release_version.py .tox/package/tmp/dist + - uses: actions/upload-artifact@v4 + with: + name: dist + path: .tox/package/tmp/dist/* + if-no-files-found: error + + smoke: + # The full suite already ran on master before the tag was pushed, and it + # needs half a dozen docker servers, so re-running it here would cost + # twenty minutes to learn nothing new. What master CI has *not* seen is + # the alpha's tree, where a dependency was removed from pyproject.toml - + # hence the emphasis on installing the built wheel with only its declared + # dependencies and checking it still imports. + name: smoke test the wheel (${{ matrix.python }}) + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.14"] + steps: + - uses: actions/checkout@v5 + with: + # The editable install below goes through hatch-vcs too. + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - name: Install the wheel with only its declared dependencies + run: | + python -m venv /tmp/wheelenv + /tmp/wheelenv/bin/pip install --upgrade pip + /tmp/wheelenv/bin/pip install dist/*.whl + /tmp/wheelenv/bin/pip show caldav + # Run from /tmp, not from the repository root, where the caldav/ source + # tree shadows the installed package and this would be testing the + # checkout rather than the wheel. + # + # Only the bare import is checked against the declared dependencies + # alone, because the alpha declares no HTTP library at all: reaching + # DAVClient without one is *meant* to raise, and does. A supported + # library is then installed - as the alpha's consumers have to - and the + # client is reached for real. + - name: Import the installed package + working-directory: /tmp + run: | + /tmp/wheelenv/bin/python -c "import caldav; print('bare import:', caldav.__version__)" + /tmp/wheelenv/bin/pip install requests + /tmp/wheelenv/bin/python -c " + import caldav + from caldav import DAVClient + print('caldav', caldav.__version__, 'imports cleanly') + " + # A fast sanity run over the tests that need no server at all - it takes + # seconds, and it catches a tag pointing at a tree that never built. + - name: Run the server-less tests + run: | + pip install --editable .[test] + pytest -q tests/test_caldav_unit.py tests/test_cdav.py tests/test_vcal.py \ + tests/test_utils.py tests/test_lazy_import.py tests/test_http_libraries.py + + publish: + name: publish to PyPI + needs: [build, smoke] + # Only real tag pushes publish; a workflow_dispatch run stops after the + # checks above, which is what makes it usable as a rehearsal. + if: github.event_name == 'push' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/caldav + permissions: + # Required for trusted publishing (and for the attestations the publish + # action generates). + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + verbose: true + + github-release: + # Some people watch the github releases page rather than PyPI, and + # forgetting this entry is on the RELEASE-HOWTO's list of mistakes. + name: create the github release + needs: [build, publish] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + # The annotated tag message is the release notes - it is written by hand + # at tagging time and there is no point in writing them twice. + - name: Create the release from the tag message + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + PRERELEASE: ${{ needs.build.outputs.prerelease }} + run: | + git tag -l --format='%(contents)' "$TAG" > /tmp/notes.md + gh release create "$TAG" dist/* \ + --title "$TAG" \ + --notes-file /tmp/notes.md \ + $([ "$PRERELEASE" = "true" ] && echo --prerelease || echo --latest) diff --git a/.lycheeignore b/.lycheeignore index 1f5fe3f3..618e9c0b 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -61,3 +61,8 @@ https://dav\.qq\.com/.* # pushes the code before the tag, so this view 404s until `v3.3.0` is pushed - # delete this entry once it is. https://github\.com/python-caldav/caldav/compare/v3\.2\.1\.\.\.v3\.3\.0 + +# Management pages behind a login - RELEASE-HOWTO links them for the +# maintainer's benefit, lychee only sees a login page or a 404. +https://pypi\.org/manage/project/caldav/.* +https://github\.com/python-caldav/caldav/settings/.* diff --git a/docs/design/RELEASE-HOWTO.md b/docs/design/RELEASE-HOWTO.md index 9ee444bb..c9a12c44 100644 --- a/docs/design/RELEASE-HOWTO.md +++ b/docs/design/RELEASE-HOWTO.md @@ -1,17 +1,24 @@ -# HOWTO make a new release [DRAFT] +# HOWTO make a new release ## Note -I have no clue on the proper procedures for doing releases, and I keep on doing clumsy mistakes - hence the need for this document. Anyway, perhaps there are better ways of doing releases? Feel free to shout out (or write up a pull-request). (Indeed - in all other projects now I'm just tagging a release and then magic happens through CI pipelines. I should consider the same for the caldav library) +Releases are cut by hand and published by CI. The manual part is the part +that needs judgement - deciding that the code is ready, and against which +servers it has been tested. Everything mechanical (building, checking the +tarball for junk, uploading to PyPI, creating the github release) is done by +the `publish` workflow, triggered by pushing a signed tag. -(And no - we cannot do auto-releases of CalDAV. Every release has to be well-tested, some of the tests needs my private configuration with passwords and usernames for various caldav servers, and some of the docker containers included are sluggish, full test runs takes very long time). +We cannot do auto-releases of CalDAV. Every release has to be well-tested, +some of the tests need private configuration with passwords and usernames for +various caldav servers, and some of the docker containers included are +sluggish - full test runs take a very long time. -## Checklist +## Before tagging * Go through changes since last release and compare it with the `CHANGELOG.md`. Any change should be logged. * Run tests towards as many servers as possible * Use the `PYTHON_CALDAV_DEBUGMODE=DEBUG_PDB` environment variable (or simply pass `--pdb` to pytest, which sets this automatically)! Should do some research if we hit any "soft asserts" or "weirdness". - * Do research on breakages. If the test breaks also for the previous release of the caldav library, then it's likely to be due to some regression on the server side. For patch-level releases we don't care about such breakages, for minor-level releases we should try to work around problems + * Do research on breakages. If the test breaks also for the previous release of the caldav library, then it's likely to be due to some regression on the server side. For patch-level releases such breakages may be allowed, for minor-level releases we should try to work around problems * It's proper to document somewhere (TODO: where? how?) what servers have been tested * Does any of the changes require documentation to be rewritten? The documentation should ideally be in sync with the code upon release time. * Look through github pull requests and see if there is anything that ought to be included in the release @@ -19,64 +26,149 @@ I have no clue on the proper procedures for doing releases, and I keep on doing * Any changes done, go back to the start of this list * Write up some release notes. (I typically keep a short summary of the changes in the CHANGELOG, and use that as the release notes). * Verify that we're on the right branch - `git checkout master`. (`master` may not always be right - sometimes we may want to use a dedicated branch connected to the release-series, i.e. `v1.3`) -* TODO - document needs to be updated - as the test runs on github now takes significant amounts of time, it's important to push the code first and wait for quite a while before tagging and pushing the tag. -* Set the variable `VERSION=2.2.0` -* Commit the changes (typically `CHANGELOG.md`, perhaps documentation): `git commit -am "preparing for releasing v${VERSION}"` -* Create a tag: `git tag -as v${VERSION}` - use the release notes in the tag message. Don't push it yet. -* Make a clone: `cd ~ ; git clone caldav/ caldav-release ; cd caldav-release ; git checkout v${VERSION}` -* Run tests (particularly the style check): `pytest` and `tox -e style`. TODO: is `tox -e style` still relevant? -* Push the code to github: `cd ~/caldav ; git push ; git push --tags` -* Some people relies on the github release system for finding releases - go to https://github.com/python-caldav/caldav/releases/new, choose the new tag, copy the version number and the release notes in. Remember to check the box to make it the latest release. -* The most important part - push to pypi. Note that the virtualenv is created - *outside* the release clone: `python -m build` packages the directory it is - pointed at, and a venv sitting inside it goes straight into the tarball. - That is how `caldav-3.2.1.tar.gz` came to contain 1755 files under `venv/`. - ``` - python3 -m venv ~/caldav-release-venv - . ~/caldav-release-venv/bin/activate - pip install -U pip build twine tox - cd ~/caldav-release - tox -e package # builds sdist+wheel and fails if anything untracked is in them - python -m build - python -m twine upload dist/* - ``` - `tox -e package` is the safety net for this whole class of mistake: it - compares the sdist file list against `git ls-files` and refuses anything git - does not track. It runs in CI too, but run it here as well - CI checks the - *repository*, this checks the *tree you are about to upload*. -* Remove the release dir and its venv: `rm -r ~/caldav-release ~/caldav-release-venv` -* Publish the companion alpha - the `niquests`-free variant. Since 3.3.0, every - release has one, so that consumers who cannot pull in `niquests` (and the - `urllib3_future.pth` it brings - see - https://github.com/python-caldav/caldav/issues/690) have something to pin. - **Tag it on a throwaway branch, never on the release branch:** - ``` - git checkout -b tmp-${VERSION}a1 v${VERSION} - ## drop "niquests" from [project] dependencies in pyproject.toml - git commit -am "chore: build ${VERSION}a1 without the niquests dependency" - git tag -as v${VERSION}a1 - ``` - then build and upload it exactly as above, from its own clean clone. Push the - tag (`git push origin v${VERSION}a1`) but **do not push or merge the branch**, - and delete it locally afterwards. The reason for the side branch is that - `hatch-vcs` derives the version from the nearest tag: an `a1` tag sitting on - the mainline as a descendant of `v${VERSION}` would make every subsequent dev - version be computed from `${VERSION}a1`, which under PEP 440 sorts *below* - `${VERSION}`. -* Note in the release notes that the alpha exists and must be pinned exactly - - `caldav==${VERSION}a1`. A range such as `caldav>=${VERSION}a1` still resolves - to the final release, since pip picks the highest eligible version. +* Push the code and **wait for the github test runs to go green** before + tagging. They take a while, and the tag is the point of no return. + +## Cutting the release + +``` +VERSION=3.3.1 +git commit -am "preparing for releasing v${VERSION}" +git push +## wait for CI on master to go green +git tag -as v${VERSION} # the tag message becomes the release notes +git push origin v${VERSION} +``` + +That is the whole release. Pushing the tag starts `.github/workflows/publish.yaml`, +which: + +1. builds the sdist and the wheel and runs `tox -e package` over them, so + nothing untracked by git can ride along into the tarball; +2. checks that the version the artifacts carry is exactly the tag + (`tests/tools/check_release_version.py`) - this is what stops a + `3.3.1.dev4+g1234567` from being published by accident; +3. installs the built wheel in a clean environment and runs the server-less + tests; +4. uploads to PyPI over [trusted publishing](https://docs.pypi.org/trusted-publishers/) - + no API token exists anywhere; +5. creates the github release, with the annotated tag's message as the release + notes and the artifacts attached. + +The tag message is therefore the release notes - write it as such. + +### The niquests-free companion alpha + +Since 3.3.0, every release has a companion `a1` that does not depend on +`niquests`, so that consumers who cannot pull in `niquests` (and the +`urllib3_future.pth` it brings - see +https://github.com/python-caldav/caldav/issues/690) have something to pin. + +**Tag it on a throwaway branch, never on the release branch:** + +``` +git checkout -b tmp-${VERSION}a1 v${VERSION} +## drop "niquests" from [project] dependencies in pyproject.toml +git commit -am "chore: build ${VERSION}a1 without the niquests dependency" +git tag -as v${VERSION}a1 +git push origin v${VERSION}a1 # the tag only - never the branch +git checkout master +git branch -D tmp-${VERSION}a1 +``` + +The same workflow picks this up and publishes it: the workflow file comes from +the tagged tree, so it builds whatever `pyproject.toml` that branch carries. +Pushing the tag pushes the commit it points at, which is all github needs; the +branch itself must not be pushed or merged, because `hatch-vcs` derives the +version from the nearest tag, and an `a1` tag sitting on the mainline as a +descendant of `v${VERSION}` would make every subsequent dev version be computed +from `${VERSION}a1` - which under PEP 440 sorts *below* `${VERSION}`. + +Two things to note in the release notes: + +* the alpha must be pinned exactly - `caldav==${VERSION}a1`. A range such as + `caldav>=${VERSION}a1` still resolves to the final release, since pip picks + the highest eligible version. +* the alpha declares *no* HTTP library at all, so `pip install caldav==${VERSION}a1` + on its own gives an installation that raises on the first `DAVClient`. Its + consumers have to bring their own `requests` (or anything else supported). + +## Rehearsing the workflow without burning a version number + +The `publish` workflow can be started by hand from the Actions tab +(`workflow_dispatch`). Everything up to and including the smoke test runs; +the publish and github-release jobs are skipped, because they are guarded on +the event being a tag push. Use this after editing the workflow. + +The second safety net is the `pypi` [github environment](https://github.com/python-caldav/caldav/settings/environments) +the publish job runs in. Tick *Required reviewers* there and a tag push stops +before the upload and waits for a human to click Approve - so a build that +looks wrong can be rejected, and the version number survives. Rejecting an +approval is free; a PyPI upload is forever. + +## One-time setup (done once, documented for the record) + +Trusted publishing has to be configured on PyPI, at +https://pypi.org/manage/project/caldav/settings/publishing/ : + +| field | value | +| --- | --- | +| Owner | `python-caldav` | +| Repository name | `caldav` | +| Workflow name | `publish.yaml` | +| Environment name | `pypi` | + +The environment name must match the `environment:` in the workflow, and the +`pypi` environment must exist on the github side too (it is created +automatically on the first run, but create it up front if you want required +reviewers from the start). + +## Verifying the tag signature - not implemented yet + +The workflow currently trusts any tag that matches the pattern. Anyone who can +push a tag to the repository can therefore publish to PyPI. The intended fix is +to verify, before anything is built, that the tag carries a good signature from +a key we know: + +```yaml + - name: Verify the tag signature + if: github.ref_type == 'tag' + env: + TAG: ${{ github.ref_name }} + run: | + gpg --import .github/release-signing-keys.asc + git verify-tag "$TAG" +``` + +with the maintainer's public key committed as +`.github/release-signing-keys.asc`. Notes for whoever implements it: + +* `git verify-tag` exits non-zero on an unsigned or lightweight tag, which is + the behaviour we want - `git tag -as` is already the documented way to tag. +* the key has to be committed to the repository (or held in a repository + *variable*, not a secret - it is public, and secrets are not available to + the workflow when it needs to fail closed). Committing it makes the + trust root reviewable in the history. +* a good signature from *some* key in the keyring is not enough; pin the + fingerprint, or import exactly the one key. +* if signing ever moves to ssh keys, `git verify-tag` needs + `gpg.ssh.allowedSignersFile` pointing at a committed allowed-signers file + instead. + +Deferred to the next patch release, deliberately - it is worth getting the +publishing path working first, and the required-reviewers gate on the `pypi` +environment covers the same ground in the meantime. ## List of mistakes to be avoided This is most likely not complete, but should explain some of the "silly" steps above ... * Forgetting to set a release git tag -* Forgetting to update the version number (or setting it wrongly) - but now `setuptools-scm` is supposed to take care of that) +* Forgetting to update the version number (or setting it wrongly) - but now `hatch-vcs` is supposed to take care of that) * Doing last-minute changes in i.e. `CHANGELOG.md` causing the style test to break * Forgetting to add new files to the git repo * Having checked out a branch or tag or something, and tagging that as the new release rather than the latest HEAD. -* Forgetting to push to pypi, or pushing something else than the tagged revision to pypi -* Pushing out junk files in the pypi-release (i.e. .pyc-files, log files, temp files, `tests/conf_private.py`, `tests/caldav_test_servers.yaml`, an entire `venv/`, etc). `tox -e package` now catches this - see the build step above +* Tagging before the github test runs on master have gone green +* Pushing out junk files in the pypi-release (i.e. .pyc-files, log files, temp files, `tests/conf_private.py`, `tests/caldav_test_servers.yaml`, an entire `venv/`, etc). `tox -e package` catches this, in the publish workflow and nightly * Forgetting the companion `a1` release, or tagging it on the release branch instead of a throwaway one (which poisons every later dev version number) -* Not adding the release to the "github releases" (I don't care much about this feature, but apparently some people check there to find the latest release version) +* Building from a tree that is not exactly on the tag, so that a `.dev` version gets uploaded. `tests/tools/check_release_version.py` catches this diff --git a/tests/tools/check_release_version.py b/tests/tools/check_release_version.py new file mode 100755 index 00000000..c446563e --- /dev/null +++ b/tests/tools/check_release_version.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +Check that the version baked into the built artifacts is the tagged version. + +Usage: + python tests/tools/check_release_version.py DISTDIR [--tag v3.3.1] + +Run by the ``publish`` workflow between building and uploading. ``hatch-vcs`` +derives the version from ``git describe``, which quietly produces a +``3.3.1.dev4+g1234567`` when the checkout is not exactly on the tag - a shallow +clone, a missing ``fetch-depth: 0``, a tag that ended up on the wrong commit. +Such a version is a perfectly valid PyPI upload, and once uploaded the number +is gone for good, so this refuses it before it can be published. + +The version is read out of the artifacts' own metadata rather than parsed off +their file names, since a wheel file name mangles anything a version may +legally contain. The comparison is between parsed PEP 440 versions rather than +strings: the tag ``v3.3.1.a1`` and the version ``3.3.1a1`` are the same +version, spelled differently. + +Without ``--tag`` the tag is taken from ``$REF_NAME``; if ``$REF_TYPE`` says the +ref is not a tag (a ``workflow_dispatch`` rehearsal on a branch), the version is +reported but not enforced. +""" + +from __future__ import annotations + +import argparse +import email +import os +import sys +import tarfile +import zipfile +from pathlib import Path + +from packaging.version import InvalidVersion, Version + + +def version_of(path: Path) -> str: + """Read the Version field out of a wheel's METADATA or an sdist's PKG-INFO.""" + if path.name.endswith(".whl"): + with zipfile.ZipFile(path) as zf: + name = next(n for n in zf.namelist() if n.endswith(".dist-info/METADATA")) + raw = zf.read(name) + elif path.name.endswith(".tar.gz"): + with tarfile.open(path) as tar: + name = next(n for n in tar.getnames() if n.count("/") == 1 and n.endswith("/PKG-INFO")) + member = tar.extractfile(name) + assert member is not None + raw = member.read() + else: + raise SystemExit(f"unexpected file in the dist directory: {path.name}") + version = email.message_from_bytes(raw).get("Version") + if not version: + raise SystemExit(f"no Version in the metadata of {path.name}") + return version + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("distdir", type=Path) + parser.add_argument("--tag", default=os.environ.get("REF_NAME", "")) + args = parser.parse_args() + + artifacts = sorted(args.distdir.iterdir()) + if not artifacts: + raise SystemExit(f"no artifacts found in {args.distdir}") + found = {path.name: version_of(path) for path in artifacts} + for name, version in found.items(): + print(f"{name}: {version}") + if len(set(found.values())) > 1: + raise SystemExit("the artifacts disagree about the version") + built = next(iter(found.values())) + + tag = args.tag + if not tag or os.environ.get("REF_TYPE", "tag") != "tag": + print("not building from a tag - the version is not enforced") + else: + try: + wanted = Version(tag.removeprefix("v")) + except InvalidVersion: + raise SystemExit(f"tag {tag} is not a PEP 440 version") from None + if Version(built) != wanted: + raise SystemExit( + f"the artifacts say {built} but the tag {tag} says {wanted}. " + "A dev or local version here means the build did not see the " + "tag (shallow clone?), or the tag is not on the built commit." + ) + print(f"✓ the artifacts match the tag {tag}") + + if github_output := os.environ.get("GITHUB_OUTPUT"): + with open(github_output, "a") as fp: + fp.write(f"version={built}\n") + fp.write(f"prerelease={str(Version(built).is_prerelease).lower()}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())