diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..3019963 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,14 @@ +name: CI + +on: + push: + branches: + - main + - 'releases/**' + workflow_call: + workflow_dispatch: + +jobs: + test_actions: + name: Test actions + uses: ./.github/workflows/test_actions.yml diff --git a/.github/workflows/PR.yml b/.github/workflows/PR.yml new file mode 100644 index 0000000..3d28ed8 --- /dev/null +++ b/.github/workflows/PR.yml @@ -0,0 +1,18 @@ +name: PR + +on: + pull_request: + branches: + - main + - 'releases/**' + workflow_call: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + run_ci: + name: Run CI + uses: ./.github/workflows/CI.yml \ No newline at end of file diff --git a/.github/workflows/test_actions.yml b/.github/workflows/test_actions.yml new file mode 100644 index 0000000..cbe4154 --- /dev/null +++ b/.github/workflows/test_actions.yml @@ -0,0 +1,86 @@ +name: Test actions + +on: + workflow_call: + workflow_dispatch: + +jobs: + test_check_project_version: + name: Test check-project-version + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Python + uses: ./setup-python + - name: Set up Poetry + uses: ./setup-poetry + - name: Create project with version 1.0.0 + run: | + poetry new test-project + poetry version 1.0.0 -C test-project + - name: Check version == 1.0.0 + uses: ./check-project-version + with: + project-directory: test-project + expected-version: 1.0.0 + - name: Create a notice about the expected failure + run: echo "::notice title=Notice::The next step is expected to fail." + - name: Check version == 1.0.1 (expected to fail) + id: expected-failure + continue-on-error: true + uses: ./check-project-version + with: + project-directory: test-project + expected-version: 1.0.1 + - name: Error if the previous step didn't fail + if: steps.expected-failure.outcome != 'failure' + run: | + echo "::error title=Test Failure::The previous step did not fail as expected." + exit 1 + test_update_project_version: + name: Test update-project-version + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Python + uses: ./setup-python + - name: Set up Poetry + uses: ./setup-poetry + - name: Create project with version 1.0.0 + run: | + poetry new test-project + poetry version 1.0.0 -C test-project + - name: Update version to 1.0.1 + uses: ./update-project-version + with: + project-directory: test-project + create-pull-request: false + version-rule: patch + use-dev-suffix: false + - name: Check version == 1.0.1 + uses: ./check-project-version + with: + project-directory: test-project + expected-version: 1.0.1 + - name: Update version to 1.0.2.dev0 + uses: ./update-project-version + with: + project-directory: test-project + create-pull-request: false + - name: Check version == 1.0.2.dev0 + uses: ./check-project-version + with: + project-directory: test-project + expected-version: 1.0.2.dev0 + - name: Update version to 1.0.2.dev1 + uses: ./update-project-version + with: + project-directory: test-project + create-pull-request: false + - name: Check version == 1.0.2.dev1 + uses: ./check-project-version + with: + project-directory: test-project + expected-version: 1.0.2.dev1 diff --git a/README.md b/README.md index 707ccec..6f3c4a3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,34 @@ `ni/python-actions` is a Git repository containing reusable GitHub Actions for NI Python projects. +## Table of Contents + +- [`ni/python-actions`](#nipython-actions) + - [Table of Contents](#table-of-contents) + - [`ni/python-actions/setup-python`](#nipython-actionssetup-python) + - [Usage](#usage) + - [Inputs](#inputs) + - [`python-version`](#python-version) + - [Outputs](#outputs) + - [`python-version`](#python-version-1) + - [`python-path`](#python-path) + - [`ni/python-actions/setup-poetry`](#nipython-actionssetup-poetry) + - [Usage](#usage-1) + - [Inputs](#inputs-1) + - [`poetry-version`](#poetry-version) + - [`ni/python-actions/check-project-version`](#nipython-actionscheck-project-version) + - [Usage](#usage-2) + - [Inputs](#inputs-2) + - [`project-directory`](#project-directory) + - [`expected-version`](#expected-version) + - [`ni/python-actions/update-project-version`](#nipython-actionsupdate-project-version) + - [Usage](#usage-3) + - [Inputs](#inputs-3) + - [`project-directory`](#project-directory-1) + - [`branch-prefix`](#branch-prefix) + - [`create-pull-request`](#create-pull-request) + - [`version-rule` and `use-dev-suffix`](#version-rule-and-use-dev-suffix) + ## `ni/python-actions/setup-python` The `setup-python` action installs Python and adds it to the PATH. @@ -15,7 +43,7 @@ By default, this action installs Python 3.11.9. ```yaml steps: -- uses: ni/python-actions/setup-python@v0.1.0 +- uses: ni/python-actions/setup-python@v0.2 ``` ### Inputs @@ -23,12 +51,13 @@ steps: #### `python-version` You can specify the `python-version` input for testing with multiple versions of Python: + ```yaml strategy: matrix: python-version: [3.9, '3.10', 3.11, 3.12, 3.13] steps: -- uses: ni/python-actions/setup-python@v0.1.0 +- uses: ni/python-actions/setup-python@v0.2 with: python-version: ${{ matrix.python-version }} ``` @@ -38,9 +67,10 @@ steps: #### `python-version` You can use the `python-version` output to get the actual version of Python, which is useful for caching: + ```yaml steps: -- uses: ni/python-actions/setup-python@v0.1.0 +- uses: ni/python-actions/setup-python@v0.2 id: setup-python - uses: actions/cache@v4 with: @@ -54,9 +84,10 @@ steps: containing the Python installation. You can also use the `python-path` output to get the path to the Python interpreter: + ```yaml steps: -- uses: ni/python-actions/setup-python@v0.1.0 +- uses: ni/python-actions/setup-python@v0.2 id: setup-python - run: pipx install --python ${{ steps.setup-python.outputs.python-version }} ``` @@ -75,8 +106,8 @@ By default, this action installs Poetry 1.8.2. ```yaml steps: -- uses: ni/python-actions/setup-python@v0.1.0 -- uses: ni/python-actions/setup-poetry@v0.1.0 +- uses: ni/python-actions/setup-python@v0.2 +- uses: ni/python-actions/setup-poetry@v0.2 - run: poetry install -v ``` @@ -86,9 +117,138 @@ steps: ```yaml steps: -- uses: ni/python-actions/setup-python@v0.1.0 -- uses: ni/python-actions/setup-poetry@v0.1.0 +- uses: ni/python-actions/setup-python@v0.2 +- uses: ni/python-actions/setup-poetry@v0.2 with: poetry-version: 2.1.3 - run: poetry install -v -``` \ No newline at end of file +``` + +## `ni/python-actions/check-project-version` + +The `check-project-version` action uses Poetry to get the version of a Python project and checks +that it matches an expected version. By default, this action checks against `github.ref_name`, which +is the GitHub release tag for GitHub release events. + +This action requires Poetry, so you must call `setup-python` and `setup-poetry` first. + +### Usage + +```yaml +steps: +- uses: ni/python-actions/setup-python@v0.2 +- uses: ni/python-actions/setup-poetry@v0.2 +- uses: ni/python-actions/check-project-version@v0.2 + if: github.event_name == 'release' +``` + +### Inputs + +#### `project-directory` + +You can specify `project-directory` to check a project located in a subdirectory. + +```yaml +- uses: ni/python-actions/check-project-version@v0.2 + with: + project-directory: packages/foo +``` + +#### `expected-version` + +You can specify `expected-version` to check against something other than `github.ref_name`. + +```yaml +- uses: ni/python-actions/check-project-version@v0.2 + with: + expected-version: ${{ steps.get-expected-version.outputs.version }} +``` + +## `ni/python-actions/update-project-version` + +The `update-project-version` action uses Poetry to update the version of a Python project and +creates a pull request to modify its `pyproject.toml` file. + +This action requires Poetry, so you must call `setup-python` and `setup-poetry` first. + +Creating a pull request requires the workflow or job to have the following `GITHUB_TOKEN` +permissions: + +```yaml +permissions: + contents: write + pull-requests: write +```` + +### Usage + +```yaml +steps: +- uses: ni/python-actions/setup-python@v0.2 +- uses: ni/python-actions/setup-poetry@v0.2 +- uses: ni/python-actions/update-project-version@v0.2 +``` + +### Inputs + +#### `project-directory` + +You can specify `project-directory` to update a project located in a subdirectory. + +```yaml +- uses: ni/python-actions/update-project-version@v0.2 + with: + project-directory: packages/foo +``` + +#### `branch-prefix` + +You can specify `branch-prefix` to customize the pull request branch names. The default value of +`users/build/` generates pull requests with names like `users/build/update-project-version-main` and +`users/build/update-project-version-releases-1.1`. + +```yaml +- uses: ni/python-actions/update-project-version@v0.2 + with: + branch-prefix: users/python-build/ +``` + +#### `create-pull-request` + +You can use `create-pull-request` and `project-directory` to update multiple projects with a single +pull request. + +```yaml +- uses: ni/python-actions/update-project-version@v0.2 + with: + project-directory: packages/foo + create-pull-request: false +- uses: ni/python-actions/update-project-version@v0.2 + with: + project-directory: packages/bar + create-pull-request: false +- uses: ni/python-actions/update-project-version@v0.2 + with: + project-directory: packages/baz + create-pull-request: true +``` + +#### `version-rule` and `use-dev-suffix` + +You can specify `version-rule` and `use-dev-suffix` to customize the versioning scheme. + +- `version-rule` specifies the rule for updating the version number. For example, `major` will + update 1.0.0 -> 2.0.0, while `patch` will update 1.0.0 -> 1.0.1. See [the docs for `poetry + version`](https://python-poetry.org/docs/cli/#version) for the list of rules and their behavior. +- `use-dev-suffix` specifies whether to use development versions like `1.0.0.dev0`. + +The defaults are `version-rule=patch` and `use-dev-suffix=true`, which have the following behavior: + +| Old Version | New Version | +| ----------- | ----------- | +| 1.0.0 | 1.0.1.dev0 | +| 1.0.1.dev0 | 1.0.1.dev1 | +| 1.0.1.dev1 | 1.0.1.dev2 | + +When you are ready to exit the "dev" phase, you should manually update the version number to the +desired release version before creating a release in GitHub. diff --git a/check-project-version/action.yml b/check-project-version/action.yml new file mode 100644 index 0000000..8e05163 --- /dev/null +++ b/check-project-version/action.yml @@ -0,0 +1,45 @@ +name: Check project version +description: Check the version of a Python project against an expected version, such as a release tag. +inputs: + project-directory: + description: Path to the directory containing pyproject.toml. + default: ${{ github.workspace }} + expected-version: + description: > + The expected version. By default, this is `github.ref_name`, which is the + tag for a `release` event. If the version has a leading 'v', it will be + stripped. + default: ${{ github.ref_name }} +runs: + using: composite + steps: + - name: Check project version + run: | + project_version="$(poetry version --short)" + expected_version="${{ inputs.expected-version }}" + # Strip the leading 'v', in case this is a GitHub release tag. + expected_version="${expected_version#v}" + + error_message="$(cat < + Specifies whether to create a pull request. Set this to false to update + the project version without creating a pull request. + default: true + version-rule: + description: > + Specifies the rule for how to update the version number, such as "major", + "minor", or "patch". See https://python-poetry.org/docs/cli/#version for + the list of rules and their behavior. + default: patch + use-dev-suffix: + description: Specifies whether to use development versions like "1.0.0.dev0". + default: true +runs: + using: composite + steps: + - name: Set variables + id: set-vars + run: | + # For release events, github.ref_name is the release tag (e.g. 1.0.0) and + # github.event.release.target_commitish is the branch/tag/commit that the + # tag was created from (e.g. main). + # + # For workflow_dispatch events, github.ref_name is the branch/tag/commit + # that the workflow was dispatched on. + base_branch="${{ github.event_name == 'release' && github.event.release.target_commitish || github.ref_name }}" + base_branch_no_slashes=$(echo $base_branch | tr '/' '-') + echo "base-branch=$base_branch" >> "$GITHUB_OUTPUT" + echo "branch-name=${{ inputs.branch-prefix }}update-project-version-$base_branch_no_slashes" >> "$GITHUB_OUTPUT" + shell: bash + - name: Update the version in pyproject.toml + run: python ${{ github.action_path }}/update_version.py ${{ inputs.version-rule }} ${{ inputs.use-dev-suffix == 'true' && '--dev' || '' }} + shell: bash + working-directory: ${{ inputs.project-directory }} + - name: Get changed files + id: get-changed-files + run: | + echo "changed-files<> "$GITHUB_OUTPUT" + # Prefix with "- " to generate a Markdown list. + git diff --name-only | awk '{ print "-", $0 }' >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + shell: bash + - name: Create pull request + if: inputs.create-pull-request == 'true' + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + base: ${{ steps.set-vars.outputs.base-branch }} + branch: ${{ steps.set-vars.outputs.branch-name }} + commit-message: "chore: Update project version - ${{ steps.set-vars.outputs.base-branch }}" + title: "chore: Update project version - ${{ steps.set-vars.outputs.base-branch }}" + # The workflow log currently points to the run, not the specific job + # within that run. Linking to a specific job requires the numeric job id, + # which is not available in the github context. + # https://stackoverflow.com/questions/71240338/obtain-job-id-from-a-workflow-run-using-contexts + body: | + This PR updates the versions of the following Python projects: + + ${{ steps.get-changed-files.outputs.changed-files }} + + This PR was generated by [ni/python-actions/update-project-version](https://github.com/ni/python-actions/). View the [workflow log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). diff --git a/update-project-version/update_version.py b/update-project-version/update_version.py new file mode 100644 index 0000000..1e6e689 --- /dev/null +++ b/update-project-version/update_version.py @@ -0,0 +1,63 @@ +"""Wrapper for ``poetry version`` that implements the "dev" bump rule. + +This script is a workaround for https://github.com/python-poetry/poetry/issues/8718 - "Add 'dev' as +version bump rule for developmental releases." +""" + +import argparse +import re +import subprocess +import sys + + +def _bump_dev_version(version: str) -> str: + """Bump the "dev" version number, if present. + + >>> _bump_dev_version("1.0.0") + '1.0.0' + >>> _bump_dev_version("1.0.0.dev0") + '1.0.0.dev1' + >>> _bump_dev_version("1.0.0-dev0") + '1.0.0-dev1' + >>> _bump_dev_version("1.0.0_dev0") + '1.0.0_dev1' + >>> _bump_dev_version("1.0.0dev0") + '1.0.0dev1' + >>> _bump_dev_version("1.0.0.dev99") + '1.0.0.dev100' + """ + # Dot, dash, and underscore are all valid. Do not bother normalizing to dot. + match = re.match(r"^(.*[\.-_]?dev)(\d+)$", version) + if match: + version = f"{match.group(1)}{int(match.group(2))+1}" + return version + + +def main(args: list[str]) -> None: + """Main function.""" + parser = argparse.ArgumentParser() + parser.add_argument("rule") + parser.add_argument("--dev", action="store_true") + args = parser.parse_args() + + version = subprocess.check_output(["poetry", "version", "--short"], text=True).strip() + + if args.dev: + if "dev" in version: + new_version = _bump_dev_version(version) + subprocess.run(["poetry", "version", new_version]) + else: + # Run `poetry version` to update the version using the specified bump rule (e.g. "1.0.0" + # -> "2.0.0", "1.1.0", or "1.0.1"), then add ".dev0" to the end. + subprocess.run(["poetry", "version", args.rule]) + new_version = subprocess.check_output( + ["poetry", "version", "--short"], text=True + ).strip() + new_version += ".dev0" + subprocess.run(["poetry", "version", new_version]) + else: + subprocess.run(["poetry", "version", args.rule]) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))