diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml new file mode 100644 index 00000000..cce63246 --- /dev/null +++ b/.github/workflows/publish_pypi.yml @@ -0,0 +1,48 @@ +# .github/workflows/publish_pypi.yml +--- +name: publish-pypi +on: + workflow_dispatch: + inputs: + tag: + description: Tag to publish, for example v1.4.0 + required: true + type: string +jobs: + pypi-publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/choreographer + # Signs this workflow so pypi trusts it + permissions: + id-token: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag }} + # setuptools-git-versioning reads the tags to set the version + fetch-depth: 0 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + - run: uv sync --locked --all-extras --no-sources + - run: git diff --quiet HEAD || { echo "Working tree dirty"; exit 1; } + - run: uv build + - name: Verify that the build matches the tag + run: | + expected="${{ inputs.tag }}" + expected="${expected#v}" + wheel="dist/choreographer-$expected-py3-none-any.whl" + sdist="dist/choreographer-$expected.tar.gz" + if [ ! -f "$wheel" ] || [ ! -f "$sdist" ]; then + echo "Expected version $expected, but dist/ holds:" + ls -1 dist/ + exit 1 + fi + echo "Built $expected from ${{ inputs.tag }}" + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + # A re-run of a tag already on PyPI skips instead of failing + skip-existing: true diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index b9a0b78c..2d0d350a 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -1,11 +1,13 @@ --- -name: ruff-wf +name: ruff on: pull_request jobs: ruff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/ruff-action@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 with: src: 'src' + # Keep this in step with the rev in .pre-commit-config.yaml + version: 0.16.8 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7ae95cb..ac14bec8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,5 @@ --- -name: test-wf +name: test on: pull_request: push: @@ -8,15 +8,19 @@ on: jobs: test-all: strategy: + fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] + # The oldest and the newest supported python: version-specific + # breakage lives at the floor, and the tag workflow covers the middle + python_v: ['3.9', '3.14'] runs-on: ${{ matrix.os }} + env: + UV_PYTHON: ${{ matrix.python_v }} steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - - uses: actions/setup-python@v5 - with: - python-version-file: "pyproject.toml" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + - run: uv python install ${{ matrix.python_v }} - name: Install Dependencies if: ${{ matrix.os == 'ubuntu-latest' }} diff --git a/.github/workflows/publish_testpypi.yml b/.github/workflows/test_and_build.yml similarity index 65% rename from .github/workflows/publish_testpypi.yml rename to .github/workflows/test_and_build.yml index 332035d7..c6c3b1d1 100644 --- a/.github/workflows/publish_testpypi.yml +++ b/.github/workflows/test_and_build.yml @@ -1,6 +1,6 @@ -# .github/workflows/publish_testpypi.yml +# .github/workflows/test_and_build.yml --- -name: test-n-build +name: test-and-build on: workflow_dispatch: push: @@ -12,17 +12,17 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python_v: ['3.9', '3.10', '3.12', '3.13', '3.14', '3.14t'] + python_v: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t'] # chrome_v: ['-1'] name: Build and Test runs-on: ${{ matrix.os }} env: UV_PYTHON: ${{ matrix.python_v }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - name: Install Dependencies if: ${{ matrix.os == 'ubuntu-latest' }} run: sudo apt-get update && sudo apt-get install xvfb @@ -68,32 +68,3 @@ jobs: CHOREO_ENABLE_DEBUG: 1 run: xvfb-run uv run --no-sync poe debug-test timeout-minutes: 8 - - testpypi-publish: - name: Upload release to TestPyPI - needs: super-test - if: ${{ !cancelled() && - !failure() && - github.event_name == 'push' && - github.run_attempt == 1 }} - runs-on: ubuntu-latest - environment: - name: testpypi - url: https://test.pypi.org/p/choreographer - # Signs this workflow so pypi trusts it - permissions: - id-token: write - steps: - - name: Checkout - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 - - uses: actions/setup-python@v5 - with: - python-version-file: "pyproject.toml" - - run: git checkout ${{ github.ref_name }} - - run: uv sync --locked --all-extras --no-sources - - run: uv build - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b87c2005..0f750872 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,8 +22,8 @@ repos: hooks: - id: add-trailing-comma - repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: v0.14.4 + # Ruff version. Keep this in step with the version in ruff.yml + rev: v0.16.8 hooks: # Run the linter. - id: ruff @@ -43,7 +43,7 @@ repos: types: [file, yaml] args: [ '-d', - "{ extends: default, rules: { colons: { max-spaces-after: -1 } } }", + "{ extends: default, rules: { colons: { max-spaces-after: -1 }, line-length: { max: 120 } } }", ] - repo: https://github.com/rhysd/actionlint rev: v1.7.8 diff --git a/.python_version b/.python_version deleted file mode 100644 index bd28b9c5..00000000 --- a/.python_version +++ /dev/null @@ -1 +0,0 @@ -3.9 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..19847a02 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,125 @@ +# How to release choreographer + +This document describes how a maintainer publishes a new version of choreographer. The primary steps are a changelog update, a git tag, and an upload to PyPI. + +## Before you start + +You need the following: + +- Write access to +- An account on PyPI with upload permissions for the `choreographer` project +- A PyPI API token, or membership of the `pypi` deployment environment +- `uv` on your machine +- A local clone of the repository with all tags + +Get the tags with this command: + +```bash +git fetch --tags +``` + +## How the version number works + +No file in the repository holds the version number. The build backend uses `setuptools-git-versioning`, so the most recent git tag sets the version. See the `[tool.setuptools-git-versioning]` table in `pyproject.toml`. + +Print the version that the current checkout produces: + +```bash +uv run --no-sync --with setuptools-git-versioning setuptools-git-versioning +``` + +On a tagged commit, the command prints the clean version, for example `1.3.0`. On any other commit, the command prints a development version, for example `1.3.0.post22+git.4a278bc2`. PyPI rejects a development version, because the version carries a local part after the plus sign. Thus only a tagged commit produces a package that you can upload. + +Tag names start with `v` and follow [semantic versioning](https://semver.org). A release candidate adds `rcN` with no separator, for example `v1.4.0rc0`. + +## Step 1: Update the changelog + +1. Open `CHANGELOG.md`. The file follows the [keep a changelog](https://keepachangelog.com) format. +2. Add a new section under `## [Unreleased]`, in the form `## [X.Y.Z] -- YYYY-MM-DD` +3. Move each entry out of the `[Unreleased]` section into the new section. Leave the `[Unreleased]` heading in place with no entries. +4. Keep the subsection order: `Added`, `Changed`, `Removed`, `Fixed` +5. Make sure that each entry links to the pull request or the issue + +Open a pull request with this change and merge the pull request into `main`. The repository uses conventional commits, so title the pull request `chore: Update files for release of vX.Y.Z`. + +## Step 2: Confirm that main is ready + +1. Open the Actions tab and confirm that `test` passed on `main` +2. Make sure that `uv.lock` matches `pyproject.toml`. The release workflow runs `uv sync --locked` and fails on a stale lock file. + + ```bash + uv lock --check + ``` + + The command exits 0 when the two files agree, and exits 1 when they do not. + +3. Make sure that your working tree is clean. The release workflow fails on a dirty tree, because a dirty tree changes the version. + +## Step 3: Tag the release + +Tag the merge commit of your changelog pull request. The project uses lightweight tags. + +> [!NOTE] +> `setuptools-git-versioning` reads a lightweight tag correctly. But plain `git describe` skips a lightweight tag and reports an older one. Pass `--tags` to see the true most recent tag. + +```bash +git checkout main +git pull +git tag v1.4.0 +git push origin v1.4.0 +``` + +> [!WARNING] +> PyPI refuses a second upload of the same version. If you must correct a release that PyPI holds already, release the next version number. A tag that no package index knows is still safe to move. + +## Step 4: Watch the release workflow + +The tag push starts the `test-and-build` workflow in `.github/workflows/test_and_build.yml`. The single `super-test` job builds the package on Linux, Windows, and macOS, across each supported Python version. The job reinstalls the built wheel, downloads chrome, runs `choreo_diagnose`, and runs the test suite. + +The workflow publishes nothing. The workflow proves that the tagged commit builds and passes on every platform. + +If `super-test` fails, read the failure before you continue. A flaky browser test does not block the release, but a build failure does. + +## Step 5: Publish to PyPI + +The `publish-pypi` workflow in `.github/workflows/publish_pypi.yml` uploads the package. The workflow authenticates with OIDC through the `pypi` deployment environment, so no token is necessary. + +1. Open the Actions tab and select `publish-pypi` +2. Select Run workflow +3. Enter the tag, for example `v1.4.0` +4. Start the run +5. Open the run and approve the deployment under Review deployments + +The `pypi` environment needs a review from the `plotly/libraries_admin` team. You can approve your own run. The job waits for that approval before GitHub issues the OIDC token, so an unapproved run never reaches PyPI. + +The workflow checks out the tag, builds the package, and confirms that the built version matches the tag. The upload carries `skip-existing`, so a second run of the same tag skips the files that PyPI holds already. + +PyPI marks a release candidate as a pre-release, so `pip install choreographer` continues to resolve to the most recent final version. + +> [!NOTE] +> To upload from your machine instead, build from a clean clone of the tag and run `uv publish` with a PyPI API token. Clear `dist/` first. `uv publish` sends every file in `dist/`, so an old artifact there fails the upload. + +## Step 6: Create the GitHub release + +`CHANGELOG.md` points readers to the GitHub releases page for more context. + +1. Open +2. Select the tag that you pushed +3. Use the tag name as the title +4. Paste the new `CHANGELOG.md` section as the body +5. For a release candidate, mark the release as a pre-release + +## Step 7: Confirm the release + +Install the package from PyPI in a clean environment: + +```bash +uv run --no-project --with choreographer==1.4.0 choreo_diagnose --no-run +``` + +If the release changes the public API, tell the maintainers of [kaleido](https://pypi.org/project/kaleido/), which depends on choreographer. + +## Current gaps + +- The `testpypi` deployment environment has no purpose now. No workflow uses that environment. +- No workflow builds or deploys the documentation. The `mkdocs` dependency group in `pyproject.toml` is commented out, and `mkdocs.yml` needs the `quimeta`, `quicopy`, and `quiapi` plugins from `mkquixote`, which installs only over SSH from a private repository. A release changes no documentation site.