From f676b453eccbb44a75eeb8b267107a94fd1cde04 Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:00:34 +0100 Subject: [PATCH 1/9] feat: enable test workflows --- .github/workflows/main.yml | 3 +++ .github/workflows/release.yml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 13057ec..0034365 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,6 +31,9 @@ jobs: - name: Install just uses: extractions/setup-just@v2 + - name: Sync dependencies + run: uv sync --all-extras --all-packages + - name: Detect package changes id: changes run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eae47dd..06b1203 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,7 +67,7 @@ jobs: uses: astral-sh/setup-uv@v4 - name: Install dependencies - run: uv sync --all-extras + run: uv sync --all-extras --all-packages - name: Build package run: | @@ -75,4 +75,4 @@ jobs: - name: Publish to PyPI run: | - uv publish + uv publish --index testpypi From 1580b809907d19075dc9c8c43707c081baf196c9 Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:04:43 +0100 Subject: [PATCH 2/9] feat: dev setup helper --- justfile | 5 +++ pyproject.toml | 8 ++-- uv.lock | 116 +++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/justfile b/justfile index 2b170b6..f439dc6 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,8 @@ +# Setup development environment +dev-setup: + uv run pre-commit install + uv sync --all-extras --all-packages + # Build the project build: uv sync --all-packages diff --git a/pyproject.toml b/pyproject.toml index f883366..7abeae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,7 @@ keywords = ["keycard", "oauth", "authentication", "security", "api"] # This is a namespace/workspace root package with no source code # All functionality is provided by workspace member packages -dependencies = [ - "pre-commit>=4.3.0", -] +dependencies = [] # Optional dependencies for development [project.optional-dependencies] @@ -38,6 +36,8 @@ dev = [ "isort>=5.13.2", "mypy>=1.14.1", "ruff>=0.12.10", + "pre-commit>=4.3.0", + "commitizen>=3.29.0", ] [project.urls] @@ -94,6 +94,8 @@ dev = [ "pytest>=8.4.1", "pytest-asyncio>=1.1.0", "ruff>=0.12.10", + "pre-commit>=4.3.0", + "commitizen>=3.29.0", ] # Configure as a namespace/workspace root package diff --git a/uv.lock b/uv.lock index a83a4e6..233a5f5 100644 --- a/uv.lock +++ b/uv.lock @@ -34,6 +34,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "argcomplete" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -258,6 +267,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "commitizen" +version = "4.8.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "prompt-toolkit" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/b8/813c896b08777d510f78ddc5122fd5f6e6ac6dd18dfd355d083cfa369f3b/commitizen-4.8.4.tar.gz", hash = "sha256:082dd895697ed548a8388e9e9ec69378089860402fbb02b464acd0a306130fca", size = 56549, upload-time = "2025-09-05T16:50:01.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/26/ffe8a58aae0ce9094fd1eb5dae902560aafa7049c359eaa376024697b21e/commitizen-4.8.4-py3-none-any.whl", hash = "sha256:8b9fe86a9ec4575765c1637882e8523a91a80bc58d3c1a6b5a62378053b2a8cf", size = 80477, upload-time = "2025-09-05T16:49:59.695Z" }, +] + [[package]] name = "coverage" version = "7.10.6" @@ -411,6 +443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/67/ac57fbef5414ce84fe0bdeb497918ab2c781ff2cbf23c1bd91334b225669/cyclopts-3.23.1-py3-none-any.whl", hash = "sha256:8e57c6ea47d72b4b565c6a6c8a9fd56ed048ab4316627991230f4ad24ce2bc29", size = 85222, upload-time = "2025-08-30T17:40:33.005Z" }, ] +[[package]] +name = "decli" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -604,6 +645,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.10.0" @@ -721,15 +774,14 @@ wheels = [ [[package]] name = "keycardai" source = { editable = "." } -dependencies = [ - { name = "pre-commit" }, -] [package.optional-dependencies] dev = [ { name = "black" }, + { name = "commitizen" }, { name = "isort" }, { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -737,6 +789,8 @@ dev = [ [package.dev-dependencies] dev = [ + { name = "commitizen" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -745,9 +799,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=24.12.0" }, + { name = "commitizen", marker = "extra == 'dev'", specifier = ">=3.29.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.13.2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.1" }, - { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.3.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.2.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.10" }, @@ -756,6 +811,8 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ + { name = "commitizen", specifier = ">=3.29.0" }, + { name = "pre-commit", specifier = ">=4.3.0" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, { name = "ruff", specifier = ">=0.12.10" }, @@ -1190,6 +1247,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -1464,6 +1533,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -1735,6 +1816,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, ] +[[package]] +name = "termcolor" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -1774,6 +1864,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1845,6 +1944,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, ] +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + [[package]] name = "werkzeug" version = "3.1.1" From 4b9fc0083547ed7a41420625c170094b4e990df5 Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:05:06 +0100 Subject: [PATCH 3/9] chore: setup tests index --- packages/mcp-fastmcp/pyproject.toml | 6 ++++++ packages/mcp/pyproject.toml | 6 ++++++ packages/oauth/pyproject.toml | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/packages/mcp-fastmcp/pyproject.toml b/packages/mcp-fastmcp/pyproject.toml index f5a7cd8..9eea861 100644 --- a/packages/mcp-fastmcp/pyproject.toml +++ b/packages/mcp-fastmcp/pyproject.toml @@ -48,6 +48,12 @@ vcs = "git" pattern = "(?P\\d+\\.\\d+\\.\\d+)-keycardai-mcp-fastmcp" style = "pep440" +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true + [tool.hatch.build.targets.wheel] packages = ["src/keycardai"] diff --git a/packages/mcp/pyproject.toml b/packages/mcp/pyproject.toml index 1081283..1856619 100644 --- a/packages/mcp/pyproject.toml +++ b/packages/mcp/pyproject.toml @@ -44,6 +44,12 @@ vcs = "git" pattern = "(?P\\d+\\.\\d+\\.\\d+)-keycardai-mcp" style = "pep440" +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true + [tool.hatch.build.targets.wheel] packages = ["src/keycardai"] diff --git a/packages/oauth/pyproject.toml b/packages/oauth/pyproject.toml index 3d010f4..7c19a8b 100644 --- a/packages/oauth/pyproject.toml +++ b/packages/oauth/pyproject.toml @@ -51,6 +51,12 @@ vcs = "git" pattern = "(?P\\d+\\.\\d+\\.\\d+)-keycardai-oauth" style = "pep440" +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true + [tool.hatch.build.targets.wheel] packages = ["src/keycardai"] From 4a7b5f0fc90b975cecd3ce82a0968afeef540bec Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:12:01 +0100 Subject: [PATCH 4/9] feat: provide preview of releases --- .github/workflows/pr.yml | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d63b06c..c607691 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -54,3 +54,67 @@ jobs: - name: Run tests run: just test + + release-preview: + runs-on: ubuntu-latest + needs: [validate-commits, lint-and-test] + if: github.event_name == 'pull_request' + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install just + uses: extractions/setup-just@v2 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Detect packages with changes + id: detect-changes + run: | + changes=$(just detect-changes) + echo "changes<> $GITHUB_OUTPUT + echo "$changes" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Check if there are any changes + if [ "$changes" = "[]" ] || [ -z "$changes" ]; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Preview changelog + if: steps.detect-changes.outputs.has_changes == 'true' + run: | + echo "## đŸ“Ļ Release Preview" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "This PR will trigger releases for the following packages:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```json' >> $GITHUB_STEP_SUMMARY + echo '${{ steps.detect-changes.outputs.changes }}' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Changelog Preview" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + just preview-changelog >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: No release preview + if: steps.detect-changes.outputs.has_changes == 'false' + run: | + echo "## đŸ“Ļ Release Preview" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "â„šī¸ This PR does not contain changes that would trigger a new release." >> $GITHUB_STEP_SUMMARY From d95c3e028b663d8ece897a182d1eaea6022ba60c Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:20:26 +0100 Subject: [PATCH 5/9] feat: preview version bumps --- .github/workflows/pr.yml | 38 +++---- justfile | 4 + scripts/version_preview.py | 219 +++++++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 scripts/version_preview.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c607691..1605062 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -5,6 +5,9 @@ on: branches: - main +permissions: + contents: read + jobs: validate-commits: runs-on: ubuntu-latest @@ -80,41 +83,36 @@ jobs: - name: Install dependencies run: uv sync --all-extras - - name: Detect packages with changes - id: detect-changes + - name: Generate release preview + id: release-preview run: | - changes=$(just detect-changes) - echo "changes<> $GITHUB_OUTPUT - echo "$changes" >> $GITHUB_OUTPUT + # Get version preview information using the Python script + version_preview=$(uv run python scripts/version_preview.py --format github-summary) + + echo "preview<> $GITHUB_OUTPUT + echo "$version_preview" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - # Check if there are any changes + # Check if there are any changes by looking for version information + changes=$(uv run python scripts/version_preview.py --format json) if [ "$changes" = "[]" ] || [ -z "$changes" ]; then echo "has_changes=false" >> $GITHUB_OUTPUT else echo "has_changes=true" >> $GITHUB_OUTPUT fi - - name: Preview changelog - if: steps.detect-changes.outputs.has_changes == 'true' + - name: Display release preview + if: steps.release-preview.outputs.has_changes == 'true' run: | - echo "## đŸ“Ļ Release Preview" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "This PR will trigger releases for the following packages:" >> $GITHUB_STEP_SUMMARY + echo "${{ steps.release-preview.outputs.preview }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo '```json' >> $GITHUB_STEP_SUMMARY - echo '${{ steps.detect-changes.outputs.changes }}' >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Changelog Preview" >> $GITHUB_STEP_SUMMARY + echo "### 📝 Changelog Preview" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY just preview-changelog >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - name: No release preview - if: steps.detect-changes.outputs.has_changes == 'false' + if: steps.release-preview.outputs.has_changes == 'false' run: | - echo "## đŸ“Ļ Release Preview" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "â„šī¸ This PR does not contain changes that would trigger a new release." >> $GITHUB_STEP_SUMMARY + echo "${{ steps.release-preview.outputs.preview }}" >> $GITHUB_STEP_SUMMARY diff --git a/justfile b/justfile index f439dc6..055cea2 100644 --- a/justfile +++ b/justfile @@ -58,6 +58,10 @@ preview-changelog BASE_BRANCH="origin/main": changelog-preview BASE_BRANCH="origin/main": uv run python scripts/changelog.py preview {{BASE_BRANCH}} +# Preview expected version changes for packages with unreleased changes +preview-versions FORMAT="markdown": + uv run python scripts/version_preview.py --format {{FORMAT}} + # Detect packages with unreleased changes detect-changes: uv run python scripts/changelog.py changes --output-format github diff --git a/scripts/version_preview.py b/scripts/version_preview.py new file mode 100644 index 0000000..c62eb08 --- /dev/null +++ b/scripts/version_preview.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +Version preview tool for workspace packages. + +This script analyzes packages with unreleased changes and provides +version bump previews using commitizen. + +Usage: + python scripts/version_preview.py [--format json|markdown|github-summary] + +Formats: + json - JSON output suitable for programmatic use + markdown - Markdown output for documentation + github-summary - GitHub Actions summary format (default) +""" + +import argparse +import json +import subprocess +import sys + + +def run_command(cmd: list[str], cwd: str | None = None) -> tuple[int, str, str]: + """Run a command and return exit code, stdout, stderr.""" + try: + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=cwd + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except Exception as e: + return 1, "", str(e) + + +def get_changed_packages() -> list[dict[str, str]]: + """Get packages with unreleased changes using the existing changelog script.""" + exit_code, stdout, stderr = run_command([ + "uv", "run", "python", "scripts/changelog.py", "changes", "--output-format", "json" + ]) + + if exit_code != 0: + raise Exception(f"Failed to detect changed packages: {stderr}") + + if not stdout.strip(): + return [] + + try: + return json.loads(stdout) + except json.JSONDecodeError as e: + raise Exception(f"Failed to parse package changes JSON: {e}") from e + + +def get_version_info(package_dir: str, package_name: str) -> dict[str, str]: + """Get version information for a package using commitizen.""" + exit_code, stdout, stderr = run_command([ + "uv", "run", "cz", "bump", "--dry-run" + ], cwd=package_dir) + + result = { + "package_name": package_name, + "package_dir": package_dir, + "has_changes": False, + "current_version": None, + "next_version": None, + "increment": None, + "error": None + } + + if exit_code != 0: + result["error"] = f"Failed to get version info: {stderr}" + return result + + if not stdout.strip(): + result["error"] = "No output from commitizen" + return result + + # Parse commitizen output + lines = stdout.split('\n') + for line in lines: + if "→" in line and line.strip().startswith("bump:"): + # Example: "bump: keycardai-oauth 0.1.0 → 0.2.0" + parts = line.split() + if len(parts) >= 5 and "→" in parts: + arrow_index = parts.index("→") + if arrow_index >= 1: + result["current_version"] = parts[arrow_index - 1] + result["next_version"] = parts[arrow_index + 1] + result["has_changes"] = True + elif line.strip().startswith("increment detected:"): + # Example: "increment detected: MINOR" + parts = line.split(":") + if len(parts) >= 2: + result["increment"] = parts[1].strip() + + return result + + +def format_as_json(version_info: list[dict[str, str]]) -> str: + """Format version information as JSON.""" + return json.dumps(version_info, indent=2) + + +def format_as_markdown(version_info: list[dict[str, str]]) -> str: + """Format version information as Markdown.""" + if not version_info: + return "No packages with unreleased changes detected." + + output = ["# Release Preview", ""] + + # Version changes section + output.extend(["## Expected Version Changes", ""]) + for info in version_info: + if info["has_changes"]: + output.append(f"- **{info['package_name']}**: {info['current_version']} → {info['next_version']} ({info['increment']})") + elif info["error"]: + output.append(f"- **{info['package_name']}**: Error - {info['error']}") + else: + output.append(f"- **{info['package_name']}**: No version change detected") + + output.append("") + + # Package details section + output.extend(["## Package Details", ""]) + for info in version_info: + output.append(f"- **Package**: {info['package_name']}") + output.append(f" - **Directory**: {info['package_dir']}") + if info["has_changes"]: + output.append(f" - **Current Version**: {info['current_version']}") + output.append(f" - **Next Version**: {info['next_version']}") + output.append(f" - **Increment Type**: {info['increment']}") + output.append("") + + return "\n".join(output) + + +def format_as_github_summary(version_info: list[dict[str, str]]) -> str: + """Format version information for GitHub Actions summary.""" + if not version_info: + return "â„šī¸ No packages with unreleased changes detected." + + output = ["## đŸ“Ļ Release Preview", ""] + output.append("This analysis shows the expected release impact:", "") + + # Version changes section + output.extend(["### 📈 Expected Version Changes", "", "```"]) + for info in version_info: + if info["has_changes"]: + output.append(f"{info['package_name']}: {info['current_version']} → {info['next_version']} ({info['increment']})") + elif info["error"]: + output.append(f"{info['package_name']}: Error - {info['error']}") + else: + output.append(f"{info['package_name']}: No version change detected") + output.extend(["```", ""]) + + # Package details section + output.extend(["### 📋 Package Details", "", "```json"]) + package_details = [] + for info in version_info: + package_details.append({ + "package_name": info["package_name"], + "package_dir": info["package_dir"], + "has_changes": info["has_changes"], + "current_version": info["current_version"], + "next_version": info["next_version"], + "increment": info["increment"] + }) + output.append(json.dumps(package_details, indent=2)) + output.extend(["```", ""]) + + return "\n".join(output) + + +def main(): + """Main function with argument parsing.""" + parser = argparse.ArgumentParser( + description="Generate version preview for packages with unreleased changes" + ) + parser.add_argument( + "--format", + choices=["json", "markdown", "github-summary"], + default="github-summary", + help="Output format (default: github-summary)" + ) + + args = parser.parse_args() + + try: + # Get packages with changes + changed_packages = get_changed_packages() + + if not changed_packages: + if args.format == "json": + print("[]") + elif args.format == "markdown": + print("No packages with unreleased changes detected.") + else: # github-summary + print("â„šī¸ No packages with unreleased changes detected.") + return + + # Get version information for each package + version_info = [] + for package in changed_packages: + info = get_version_info(package["package_dir"], package["package_name"]) + version_info.append(info) + + # Format and output results + if args.format == "json": + print(format_as_json(version_info)) + elif args.format == "markdown": + print(format_as_markdown(version_info)) + else: # github-summary + print(format_as_github_summary(version_info)) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() From 013957c24ebf9cb85550517b3cf7ac209770aa1b Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:32:14 +0100 Subject: [PATCH 6/9] feat: post release information to PR --- .github/workflows/pr.yml | 9 ++ scripts/pr_comment.py | 204 +++++++++++++++++++++++++++++++++++++ scripts/version_preview.py | 2 +- 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 scripts/pr_comment.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1605062..dbea7d2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -116,3 +116,12 @@ jobs: if: steps.release-preview.outputs.has_changes == 'false' run: | echo "${{ steps.release-preview.outputs.preview }}" >> $GITHUB_STEP_SUMMARY + + - name: Comment on PR with release preview + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.number }} + run: | + uv run python scripts/pr_comment.py diff --git a/scripts/pr_comment.py b/scripts/pr_comment.py new file mode 100644 index 0000000..52e4d8a --- /dev/null +++ b/scripts/pr_comment.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +PR comment management for release previews. + +This script creates or updates PR comments with release preview information +when changes are detected in the monorepo using the GitHub CLI (gh). + +Usage: + python scripts/pr_comment.py [--pr-number PR_NUMBER] + +Environment Variables: + GITHUB_TOKEN - GitHub token for API access (used by gh CLI) + GH_REPO - Repository in format owner/repo (used by gh CLI) + PR_NUMBER - Pull request number +""" + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +def run_command(cmd: list[str], cwd: str | None = None) -> tuple[int, str, str]: + """Run a command and return exit code, stdout, stderr.""" + try: + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=cwd + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except Exception as e: + return 1, "", str(e) + + +def check_gh_cli() -> bool: + """Check if gh CLI is available.""" + exit_code, _, _ = run_command(["gh", "--version"]) + return exit_code == 0 + + +def get_existing_comment_id(pr_number: int) -> str | None: + """Find existing release preview comment ID using gh CLI.""" + cmd = ["gh", "pr", "view", str(pr_number), "--json", "comments"] + + exit_code, stdout, stderr = run_command(cmd) + + if exit_code != 0: + raise Exception(f"Failed to get PR comments: {stderr}") + + try: + pr_data = json.loads(stdout) + comments = pr_data.get("comments", []) + + # Look for comments with our signature + for comment in comments: + if "This comment was automatically generated by the release preview workflow" in comment.get("body", ""): + return comment.get("id") + + return None + except json.JSONDecodeError as e: + raise Exception(f"Failed to parse PR comments JSON: {e}") from e + + +def get_release_preview() -> str: + """Get the release preview using the version preview script.""" + exit_code, stdout, stderr = run_command([ + "uv", "run", "python", "scripts/version_preview.py", "--format", "github-summary" + ]) + + if exit_code != 0: + raise Exception(f"Failed to get release preview: {stderr}") + + return stdout + + +def get_changelog_preview() -> str: + """Get the changelog preview using just command.""" + exit_code, stdout, stderr = run_command([ + "just", "preview-changelog" + ]) + + if exit_code != 0: + raise Exception(f"Failed to get changelog preview: {stderr}") + + return stdout + + +def has_changes() -> bool: + """Check if there are any packages with changes.""" + exit_code, stdout, stderr = run_command([ + "uv", "run", "python", "scripts/version_preview.py", "--format", "json" + ]) + + if exit_code != 0: + return False + + try: + changes = json.loads(stdout) + return len(changes) > 0 + except json.JSONDecodeError: + return False + + +def create_comment_body() -> str: + """Create the full comment body with release preview and changelog.""" + release_preview = get_release_preview() + changelog_preview = get_changelog_preview() + + return f"""{release_preview} + +### 📝 Changelog Preview + +``` +{changelog_preview} +``` + +--- +*This comment was automatically generated by the release preview workflow.*""" + + +def create_or_update_comment(pr_number: int, comment_body: str) -> None: + """Create a new comment or update existing one using gh CLI.""" + if not check_gh_cli(): + raise Exception("gh CLI is not available. Please install GitHub CLI.") + + # Create temporary file with comment body + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: + f.write(comment_body) + temp_file = f.name + + try: + # Check if we have an existing comment to update + existing_comment_id = get_existing_comment_id(pr_number) + + if existing_comment_id: + # Update existing comment + cmd = ["gh", "api", f"repos/{{owner}}/{{repo}}/issues/comments/{existing_comment_id}", + "--method", "PATCH", "--field", f"body=@{temp_file}"] + + exit_code, stdout, stderr = run_command(cmd) + + if exit_code != 0: + raise Exception(f"Failed to update comment: {stderr}") + + print(f"Updated existing comment {existing_comment_id}") + else: + # Create new comment using gh pr comment + cmd = ["gh", "pr", "comment", str(pr_number), "--body-file", temp_file] + + exit_code, stdout, stderr = run_command(cmd) + + if exit_code != 0: + raise Exception(f"Failed to create comment: {stderr}") + + print("Created new comment") + finally: + # Clean up temporary file + try: + Path(temp_file).unlink() + except Exception: + pass # Ignore cleanup errors + + +def main(): + """Main function with argument parsing.""" + parser = argparse.ArgumentParser( + description="Create or update PR comment with release preview" + ) + parser.add_argument( + "--pr-number", + type=int, + help="Pull request number (can also use PR_NUMBER env var)" + ) + + args = parser.parse_args() + + # Get configuration from args or environment + pr_number = args.pr_number or os.getenv("PR_NUMBER") + + if not pr_number: + print("Error: PR number not provided via --pr-number or PR_NUMBER env var", file=sys.stderr) + sys.exit(1) + + try: + # Check if there are any changes + if not has_changes(): + print("No changes detected, skipping PR comment") + return + + # Create comment body + comment_body = create_comment_body() + + # Create or update comment + create_or_update_comment(int(pr_number), comment_body) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/version_preview.py b/scripts/version_preview.py index c62eb08..06527eb 100644 --- a/scripts/version_preview.py +++ b/scripts/version_preview.py @@ -138,7 +138,7 @@ def format_as_github_summary(version_info: list[dict[str, str]]) -> str: return "â„šī¸ No packages with unreleased changes detected." output = ["## đŸ“Ļ Release Preview", ""] - output.append("This analysis shows the expected release impact:", "") + output.extend(["This analysis shows the expected release impact:", ""]) # Version changes section output.extend(["### 📈 Expected Version Changes", "", "```"]) From e13b12e8a6be0b7b8cf016d1f0fe71ca9b7891b3 Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:37:55 +0100 Subject: [PATCH 7/9] fix: permission to write to pr --- .github/workflows/pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dbea7d2..c30e8f7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -7,6 +7,7 @@ on: permissions: contents: read + pull-requests: write jobs: validate-commits: From f620b06f5e2c7bc33c057c5abf13e1352ff192d0 Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:41:18 +0100 Subject: [PATCH 8/9] fix: display preview --- .github/workflows/pr.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c30e8f7..71e9a17 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -105,18 +105,24 @@ jobs: - name: Display release preview if: steps.release-preview.outputs.has_changes == 'true' run: | - echo "${{ steps.release-preview.outputs.preview }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### 📝 Changelog Preview" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY + # Use cat with here document to safely handle special characters + cat >> $GITHUB_STEP_SUMMARY << 'EOF' + ${{ steps.release-preview.outputs.preview }} + + ### 📝 Changelog Preview + + ``` + EOF just preview-changelog >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - name: No release preview if: steps.release-preview.outputs.has_changes == 'false' run: | - echo "${{ steps.release-preview.outputs.preview }}" >> $GITHUB_STEP_SUMMARY + # Use cat with here document to safely handle special characters + cat >> $GITHUB_STEP_SUMMARY << 'EOF' + ${{ steps.release-preview.outputs.preview }} + EOF - name: Comment on PR with release preview if: github.event_name == 'pull_request' From ae2135b53be0a66cb809068e03951dac7e693cda Mon Sep 17 00:00:00 2001 From: Kamil Potrec Date: Wed, 10 Sep 2025 14:45:09 +0100 Subject: [PATCH 9/9] fix: detect existng comments better --- scripts/pr_comment.py | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/scripts/pr_comment.py b/scripts/pr_comment.py index 52e4d8a..465123b 100644 --- a/scripts/pr_comment.py +++ b/scripts/pr_comment.py @@ -47,20 +47,31 @@ def get_existing_comment_id(pr_number: int) -> str | None: exit_code, stdout, stderr = run_command(cmd) if exit_code != 0: - raise Exception(f"Failed to get PR comments: {stderr}") + print(f"Warning: Failed to get PR comments: {stderr}") + return None try: pr_data = json.loads(stdout) comments = pr_data.get("comments", []) # Look for comments with our signature - for comment in comments: - if "This comment was automatically generated by the release preview workflow" in comment.get("body", ""): - return comment.get("id") + signature = "This comment was automatically generated by the release preview workflow" + for comment in comments: + comment_body = comment.get("body", "") + # Also check for release preview header as backup + if (signature in comment_body or + "đŸ“Ļ Release Preview" in comment_body): + comment_id = comment.get("id") + if comment_id: + print(f"Found existing comment with ID: {comment_id}") + return str(comment_id) + + print("No existing release preview comment found") return None except json.JSONDecodeError as e: - raise Exception(f"Failed to parse PR comments JSON: {e}") from e + print(f"Warning: Failed to parse PR comments JSON: {e}") + return None def get_release_preview() -> str: @@ -135,18 +146,31 @@ def create_or_update_comment(pr_number: int, comment_body: str) -> None: existing_comment_id = get_existing_comment_id(pr_number) if existing_comment_id: - # Update existing comment + print(f"Found existing comment {existing_comment_id}, attempting to update...") + + # Try to update existing comment cmd = ["gh", "api", f"repos/{{owner}}/{{repo}}/issues/comments/{existing_comment_id}", "--method", "PATCH", "--field", f"body=@{temp_file}"] exit_code, stdout, stderr = run_command(cmd) if exit_code != 0: - raise Exception(f"Failed to update comment: {stderr}") + print(f"Failed to update existing comment (it may have been deleted): {stderr}") + print("Creating new comment instead...") + + # Fall back to creating a new comment + cmd = ["gh", "pr", "comment", str(pr_number), "--body-file", temp_file] + exit_code, stdout, stderr = run_command(cmd) + + if exit_code != 0: + raise Exception(f"Failed to create comment: {stderr}") - print(f"Updated existing comment {existing_comment_id}") + print("Created new comment") + else: + print(f"Updated existing comment {existing_comment_id}") else: # Create new comment using gh pr comment + print("No existing comment found, creating new comment...") cmd = ["gh", "pr", "comment", str(pr_number), "--body-file", temp_file] exit_code, stdout, stderr = run_command(cmd)