diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2178f17d..700065e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,15 @@ on: tags: - "v*" -permissions: - contents: write - id-token: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: release: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v6 - name: Set up uv @@ -73,7 +75,8 @@ jobs: from pathlib import Path dist = Path("dist") - for prefix in ("pipefy_sdk", "pipefy_mcp_server", "pipefy_cli", "pipefy_auth", "pipefy_infra"): + expected = ("pipefy_sdk", "pipefy_mcp_server", "pipefy_cli", "pipefy_auth", "pipefy_infra") + for prefix in expected: wheels = list(dist.glob(f"{prefix}-*.whl")) if len(wheels) != 1: print( @@ -81,31 +84,63 @@ jobs: file=sys.stderr, ) sys.exit(1) + # Bound the published set: any wheel beyond the five known packages + # (e.g. a new workspace member) must fail here rather than be silently + # published, since the publish step copies every wheel in dist/. + all_wheels = list(dist.glob("*.whl")) + if len(all_wheels) != len(expected): + print( + f"expected exactly {len(expected)} wheels in dist/, got " + f"{sorted(w.name for w in all_wheels)!r}", + file=sys.stderr, + ) + sys.exit(1) PY - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: dist/*.whl body_path: CHANGELOG_RELEASE_BODY.md - - name: Stage PyPI wheels (SDK + CLI + MCP + Auth + Infra) - if: startsWith(github.ref_name, 'v1.') && !contains(github.ref_name, '-') + - name: Upload built distributions + if: vars.PYPI_PUBLISH_ENABLED == 'true' + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + retention-days: 1 + + publish-pypi: + needs: release + if: vars.PYPI_PUBLISH_ENABLED == 'true' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Stage wheels for PyPI (exclude sdists) + # The release job already verified exactly one wheel per package and no + # extras before uploading the artifact, so publish the wheels and skip + # the sdists. run: | - mkdir -p dist-pypi shopt -s nullglob - sdk=(dist/pipefy_sdk-*.whl) - cli=(dist/pipefy_cli-*.whl) - mcp=(dist/pipefy_mcp_server-*.whl) - auth=(dist/pipefy_auth-*.whl) - infra=(dist/pipefy_infra-*.whl) - if [ "${#sdk[@]}" -ne 1 ] || [ "${#cli[@]}" -ne 1 ] || [ "${#mcp[@]}" -ne 1 ] || [ "${#auth[@]}" -ne 1 ] || [ "${#infra[@]}" -ne 1 ]; then - echo "Expected one SDK, one CLI, one MCP, one Auth, and one Infra wheel in dist/" - echo "sdk=${#sdk[@]} cli=${#cli[@]} mcp=${#mcp[@]} auth=${#auth[@]} infra=${#infra[@]}" - ls -la dist || true + wheels=(dist/*.whl) + if [ ${#wheels[@]} -eq 0 ]; then + echo "no wheels in dist/ to publish" >&2 exit 1 fi - cp "${sdk[0]}" "${cli[0]}" "${mcp[0]}" "${auth[0]}" "${infra[0]}" dist-pypi/ + mkdir -p dist-pypi + cp "${wheels[@]}" dist-pypi/ - name: Smoke install staged wheels (clean venv) - if: startsWith(github.ref_name, 'v1.') && !contains(github.ref_name, '-') run: | python -m venv .venv-pypi-smoke .venv-pypi-smoke/bin/python -m pip install --upgrade pip @@ -113,7 +148,9 @@ jobs: .venv-pypi-smoke/bin/pipefy --help .venv-pypi-smoke/bin/pipefy-mcp-server --help - name: Upload to PyPI - if: startsWith(github.ref_name, 'v1.') && !contains(github.ref_name, '-') uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist-pypi/ + # Idempotent re-runs: a re-pushed tag at an already-published version + # skips existing files instead of failing the release on PyPI's 400. + skip-existing: true diff --git a/README.md b/README.md index a4f709af..2c076012 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ Feedback and issues: [GitHub Issues](https://github.com/pipefy/ai-toolkit/issues ## Installation -> Pre-1.0 ships from this git repo via `uvx` and `uv tool install`. PyPI becomes the canonical source at **v1.0**. The current beta line is **`v0.2.0-beta.*`** (first tag: [`v0.2.0-beta.1`](https://github.com/pipefy/ai-toolkit/releases/tag/v0.2.0-beta.1)). Two install paths: the **Quick install** script below (resolves the latest GitHub Release at runtime and runs `uv tool install` for you), or **Claude Code** via the plugin marketplace. +> The toolkit ships from this git repo via `uvx` and `uv tool install`. PyPI is the canonical source once publishing is enabled (gated by a release-time toggle, see [`RELEASE.md`](RELEASE.md)); until these packages are on PyPI, use the git paths below. The current beta line is **`v0.2.0-beta.*`** (first tag: [`v0.2.0-beta.1`](https://github.com/pipefy/ai-toolkit/releases/tag/v0.2.0-beta.1)). Two install paths: the **Quick install** script below (resolves the latest GitHub Release at runtime and runs `uv tool install` for you), or **Claude Code** via the plugin marketplace. > -> The CLI snippets below pin **`@latest`**, a moving git tag the release flow updates to point at the most recent release. To pin a specific version, swap `@latest` for a version tag (e.g. `@v0.2.0-beta.2`). The `--with pipefy-sdk @ ...#subdirectory=packages/sdk` / `pipefy-auth @ ...#subdirectory=packages/auth` flags are required pre-1.0: this repo is a uv workspace, and the workspace members are not yet published to PyPI, so uv needs them named explicitly. The flags go away at v1.0 (PyPI install). +> The CLI snippets below pin **`@latest`**, a moving git tag the release flow updates to point at the most recent release. To pin a specific version, swap `@latest` for a version tag (e.g. `@v0.2.0-beta.2`). The `--with pipefy-sdk @ ...#subdirectory=packages/sdk` / `pipefy-auth @ ...#subdirectory=packages/auth` flags are required while the workspace members are not yet on PyPI: this repo is a uv workspace, so uv needs them named explicitly. The flags go away once the packages are published to PyPI. Two auth paths: diff --git a/RELEASE.md b/RELEASE.md index 7f8d2686..7ca7c674 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,13 +2,16 @@ Workspace distributions (`pipefy-sdk`, `pipefy-mcp-server`, `pipefy-cli`, `pipefy-auth`, `pipefy-infra`) share a single **lockstep** version string in each package's `__init__.py`. CI fails if those values diverge. -## Pre-launch (v0.x): GitHub Release only +## Cutting a release -PyPI publishing is **disabled** for tags that do not start with `v1.`. Pre-release installs use git references (for example `uvx --from git+https://github.com//.git@vX.Y.Z --refresh pipefy-cli`). +Every `v*` tag builds all five wheels and sdists and creates a GitHub Release with the wheels attached. PyPI publishing runs on top of that, gated by the `PYPI_PUBLISH_ENABLED` repository variable (Settings > Secrets and variables > Actions > Variables): + +- **Unset or not `true`** (current state, while the PyPI account is being provisioned): GitHub Release only. Installs use git references, for example `uvx --from git+https://github.com//.git@vX.Y.Z --refresh pipefy-cli`. +- **`true`**: the `publish-pypi` job also uploads all five wheels to PyPI via Trusted Publishing, on **every** release tag including the `v0.2.0-beta.*` line. There is no version-based gate. Betas publish as PEP 440 pre-releases (a `v0.2.0-beta.4` tag ships `0.2.0b4`, which `pip` installs only with `--pre`). ### Public beta line (`v0.2.0-beta.*`) -The next **GitHub pre-release** after the standalone repo’s [`v0.1.0-beta.1`](https://github.com/pipefy/ai-toolkit/releases/tag/v0.1.0-beta.1) is the **`v0.2.0-beta.*`** series on this monorepo (first cut: **`v0.2.0-beta.1`** unless you intentionally reuse another suffix). Same mechanics as any other **v0.x** tag: attach wheels to the GitHub Release; **no PyPI** until **`v1.`**. +The next **GitHub pre-release** after the standalone repo's [`v0.1.0-beta.1`](https://github.com/pipefy/ai-toolkit/releases/tag/v0.1.0-beta.1) is the **`v0.2.0-beta.*`** series on this monorepo (first cut: **`v0.2.0-beta.1`** unless you intentionally reuse another suffix). Same mechanics as any other tag: wheels attach to the GitHub Release, and they also go to PyPI once `PYPI_PUBLISH_ENABLED` is `true`. The Release workflow requires the git tag (without leading `v`) to **exactly match** `__version__` in `packages/sdk/src/pipefy_sdk/__init__.py` (and the MCP/CLI/Auth/Infra copies). For example tag **`v0.2.0-beta.1`** implies **`__version__ = "0.2.0-beta.1"`** in all five packages before you push the tag (set via step 2 below using `version=0.2.0-beta.1`, or edit the five `__init__.py` files together). @@ -72,22 +75,29 @@ uvx --from "git+https://github.com/pipefy/ai-toolkit.git@vX.Y.Z" --refresh pipef # Expected: help text (server may block in stdio mode — Ctrl-C after banner) ``` -## v1.0 and later: GitHub Release + PyPI +## PyPI publishing -Same steps as above. For tags whose name starts with **`v1.`** (for example `v1.0.0` or `v1.0.0rc1`), the release workflow also runs **Trusted Publishing** to upload the **`pipefy-cli`** and **`pipefy-mcp-server`** wheels to PyPI via `pypa/gh-action-pypi-publish` (the `pipefy-sdk` wheel is built for the GitHub Release but is **not** uploaded to PyPI until maintainers enable it in the workflow; see **Repository setup** below). +When `PYPI_PUBLISH_ENABLED` is `true`, the `publish-pypi` job runs after the GitHub Release on every release tag and uses **Trusted Publishing** to upload all five wheels (**`pipefy-cli`**, **`pipefy-mcp-server`**, **`pipefy-sdk`**, **`pipefy-auth`**, **`pipefy-infra`**) to PyPI via `pypa/gh-action-pypi-publish`. All five are required: `pipefy-cli` and `pipefy-mcp-server` depend on `pipefy-sdk` and `pipefy-auth`, which depend on `pipefy-infra`, so a public `pip install` only resolves if every package in that closure is on PyPI. See **Repository setup** below. **Repository setup (maintainers):** -- Configure [Trusted Publishers](https://docs.pypi.org/trusted-publishers/using-a-publisher/) on PyPI for **`pipefy-cli`** and **`pipefy-mcp-server`** (and `pipefy-sdk` later if you enable it in the workflow). -- No long-lived PyPI token is required when using OIDC; the workflow requests `id-token: write`. +- Configure [Trusted Publishers](https://docs.pypi.org/trusted-publishers/using-a-publisher/) on PyPI for all five distributions: **`pipefy-cli`**, **`pipefy-mcp-server`**, **`pipefy-sdk`**, **`pipefy-auth`**, and **`pipefy-infra`**. The upload is a single batch, so if any of the five lacks a configured publisher, PyPI rejects that file and the release fails. +- For the first publish, the projects do not exist on PyPI yet. Register each of the five as a [pending publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/); the project is created on the first successful publish. Each publisher points at owner `pipefy`, repo `ai-toolkit`, workflow `release.yml`, environment `pypi`. +- No long-lived PyPI token is required when using OIDC; the `publish-pypi` job requests `id-token: write`. +- Publishing stays off until the `PYPI_PUBLISH_ENABLED` repository variable is set to `true`. Flip it on only after the five (pending) publishers exist, since one missing publisher fails the whole release. +- The `publish-pypi` job runs under the `pypi` GitHub Actions environment; add protection rules to that environment (required reviewers, allowed tags) if you want an approval gate before each upload. -**After a v1.x tag:** +**After a published tag:** -1. Confirm the new versions appear on PyPI for each published package. +1. Confirm the new versions appear on PyPI for each of the five packages. 2. Smoke-test a clean install, for example: ```bash + # Final (non-prerelease) tags: uv tool install pipefy-cli + + # Beta tags publish PEP 440 pre-releases, so allow them explicitly: + uv tool install --prerelease=allow pipefy-cli ``` ## Automation reference @@ -96,4 +106,4 @@ Same steps as above. For tags whose name starts with **`v1.`** (for example `v1. | --- | --- | | `scripts/bump_version.py` | Reads the SDK `__version__`, applies the bump, writes the same value to SDK, MCP, CLI, Auth, and Infra `__init__.py` plus the root `pyproject.toml`'s `[project].version`, then runs `uv lock` to refresh `uv.lock`. Also exposes a `verify` mode that asserts every version-bearing file agrees. | | `.github/workflows/ci.yml` | Invokes `scripts/bump_version.py verify` to assert the five `__version__` strings and root `pyproject.toml` `[project].version` all match. | -| `.github/workflows/release.yml` | On `v*` tags: asserts the tag matches SDK `__version__`, extracts the matching `CHANGELOG.md` section as the GitHub Release body, builds wheels and sdists with `uv build --all-packages -o dist --wheel --sdist`, attaches wheels to the GitHub Release, and publishes CLI + MCP wheels to PyPI only when `github.ref_name` starts with `v1.`. | +| `.github/workflows/release.yml` | On `v*` tags, the `release` job asserts the tag matches SDK `__version__`, extracts the matching `CHANGELOG.md` section as the GitHub Release body, builds wheels and sdists with `uv build --all-packages -o dist --wheel --sdist`, and attaches wheels to the GitHub Release. A separate `publish-pypi` job (environment `pypi`) then uploads all five wheels (CLI, MCP, SDK, Auth, Infra) to PyPI via Trusted Publishing when the `PYPI_PUBLISH_ENABLED` repository variable is `true`. | diff --git a/docs/DEPRECATION.md b/docs/DEPRECATION.md index 6ac6c920..aeb61a1f 100644 --- a/docs/DEPRECATION.md +++ b/docs/DEPRECATION.md @@ -1,6 +1,6 @@ # Deprecation and versioning (post-1.0) -This policy applies **after v1.0.0**, when published `pipefy-cli` and `pipefy-mcp-server` follow public [semantic versioning](https://semver.org/) on PyPI. Before that milestone, interfaces may still change without the guarantees below. +This policy applies **after v1.0.0**, when `pipefy-cli` and `pipefy-mcp-server` follow public [semantic versioning](https://semver.org/). Before that milestone, interfaces may still change without the guarantees below, including any pre-release versions published to PyPI (the v1.0 stability guarantee is independent of when packages first appear on PyPI). ## Version bumps diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 60159e5c..3a90bc78 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -6,7 +6,7 @@ Existing users of `pipefy-mcp-server`: this guide covers what changed and what t ## Package name — unchanged -`pipefy-mcp-server` is the same PyPI package name as before. Your `pip install pipefy-mcp-server` or `uvx pipefy-mcp-server` still works. The existing PyPI version is frozen at the pre-monorepo release; new versions ship at v1.0 via the same name. +`pipefy-mcp-server` is the same PyPI package name as before. Your `pip install pipefy-mcp-server` or `uvx pipefy-mcp-server` still works. The existing PyPI version is frozen at the pre-monorepo release; new versions ship to the same name once PyPI publishing is enabled (see [`RELEASE.md`](../RELEASE.md)). **Pre-launch (v0.1 → v0.5):** install from git to get the latest: diff --git a/packages/auth/pyproject.toml b/packages/auth/pyproject.toml index 9fe91bd4..0f95771c 100644 --- a/packages/auth/pyproject.toml +++ b/packages/auth/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "httpx-auth>=0.23.1", "keyring>=24", "keyrings.alt>=5", - "pipefy-infra", + "pipefy-infra==0.2.0-beta.3", "pydantic>=2.13.4,<3", "pydantic-settings>=2.8.1", ] diff --git a/packages/cli/README.md b/packages/cli/README.md index a9f2b294..6518fda5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -21,7 +21,7 @@ uv tool install \ "git+https://github.com/pipefy/ai-toolkit@latest#subdirectory=packages/cli" ``` -The `--with` flags are required pre-1.0 because the workspace members are not yet on PyPI. At v1.0 this collapses to `uv tool install pipefy-cli`. +The `--with` flags are required while the workspace members are not yet on PyPI. Once they are published, this collapses to `uv tool install pipefy-cli`. ## Quick start diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index 740772b4..948de2b7 100644 --- a/packages/cli/pyproject.toml +++ b/packages/cli/pyproject.toml @@ -5,8 +5,8 @@ description = "Typer CLI for Pipefy (pipefy-sdk)." readme = "README.md" requires-python = ">=3.11" dependencies = [ - "pipefy-sdk", - "pipefy-auth", + "pipefy-sdk==0.2.0-beta.3", + "pipefy-auth==0.2.0-beta.3", "typer>=0.12", "rich>=13", "pydantic-settings>=2.8.1", diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5b02d7af..9edb2175 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -12,7 +12,7 @@ uvx \ --refresh pipefy-mcp-server ``` -The `--with pipefy-sdk` / `pipefy-auth` flags are required pre-1.0 because the workspace members are not yet on PyPI. At v1.0 this collapses to `uvx pipefy-mcp-server`. +The `--with pipefy-sdk` / `pipefy-auth` flags are required while the workspace members are not yet on PyPI. Once they are published, this collapses to `uvx pipefy-mcp-server`. For per-client wiring (Claude Code / Cursor / Claude Desktop / Codex), see [root `README.md#installation`](../../README.md#installation). diff --git a/packages/mcp/pyproject.toml b/packages/mcp/pyproject.toml index c3a356a0..7036442a 100644 --- a/packages/mcp/pyproject.toml +++ b/packages/mcp/pyproject.toml @@ -8,8 +8,8 @@ dependencies = [ "httpx>=0.27.0", "mcp[cli]>=1.25.0", # Workspace resolves versions in dev; PyPI releases stage these wheels alongside CLI/MCP (see release workflow). - "pipefy-auth", - "pipefy-sdk", + "pipefy-auth==0.2.0-beta.3", + "pipefy-sdk==0.2.0-beta.3", "pydantic>=2.13.4,<3", "pydantic-settings>=2.8.1", ] diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 48caf416..8f183348 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -4,7 +4,7 @@ ## Status -Workspace-internal in v0.1; publishing **`pipefy-sdk`** to PyPI is gated separately from CLI/MCP (see **`RELEASE.md`** Trusted Publishing notes). +Workspace-internal in v0.1. When PyPI publishing is enabled, `pipefy-sdk` ships to PyPI in the same single batch as CLI, MCP, Auth, and Infra (all five share one lockstep version); it is not gated separately (see **`RELEASE.md`** Trusted Publishing notes). ## Usage (within the monorepo) diff --git a/packages/sdk/pyproject.toml b/packages/sdk/pyproject.toml index d4f66456..170fa904 100644 --- a/packages/sdk/pyproject.toml +++ b/packages/sdk/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "gql[httpx]>=4.0.0,<5", "httpx>=0.27.0", "openpyxl>=3.1.5", - "pipefy-infra", + "pipefy-infra==0.2.0-beta.3", "pydantic>=2.13.4,<3", "pydantic-settings>=2.8.1", "rapidfuzz>=3.14.3", diff --git a/scripts/bump_version.py b/scripts/bump_version.py index e6b7ebcd..cc691b21 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -28,6 +28,17 @@ REPO_ROOT / "packages/infra/src/pipefy_infra/__init__.py", ) +# Each published package pins its workspace siblings to the exact lockstep +# version in its built metadata, so `pip install pipefy-cli==X` pulls +# pipefy-sdk==X rather than a newer sibling that happens to be on PyPI. The +# [tool.uv.sources] workspace mapping overrides these pins for in-repo dev. +WORKSPACE_DEP_PINS: dict[Path, tuple[str, ...]] = { + REPO_ROOT / "packages/sdk/pyproject.toml": ("pipefy-infra",), + REPO_ROOT / "packages/auth/pyproject.toml": ("pipefy-infra",), + REPO_ROOT / "packages/cli/pyproject.toml": ("pipefy-sdk", "pipefy-auth"), + REPO_ROOT / "packages/mcp/pyproject.toml": ("pipefy-sdk", "pipefy-auth"), +} + # Anchored to the [project] table. The middle alternation walks lines that # don't start with `[` (so a sibling [tool.X] header ends the run), but the # line CONTENTS can contain `[` (so `classifiers = [...]`, `dependencies = @@ -91,6 +102,34 @@ def write_version_to_all_files(new_version: str) -> None: ROOT_PYPROJECT.write_text(new_root, encoding="utf-8") +def workspace_dep_pin_re(dep_name: str) -> re.Pattern[str]: + """Match a quoted ``dep_name`` requirement, with or without an existing ``==`` pin. + + The name must fill the entire quoted string, so the unquoted + ``[tool.uv.sources]`` keys and a parenthesized mention inside a description + are not matched. A package's own ``name = "..."`` field IS a full quoted + string and would match; that collision is avoided by ``WORKSPACE_DEP_PINS`` + never listing a package's own name (a package cannot depend on itself). + """ + return re.compile(rf'(["\']){re.escape(dep_name)}(?:\s*==[^"\']*)?(["\'])') + + +def write_dep_pins(new_version: str) -> None: + """Pin each package's workspace-sibling dependencies to ``new_version``.""" + for path, deps in WORKSPACE_DEP_PINS.items(): + text = path.read_text(encoding="utf-8") + for dep in deps: + text, count = workspace_dep_pin_re(dep).subn( + rf"\g<1>{dep}=={new_version}\g<2>", + text, + count=1, + ) + if count != 1: + msg = f"Expected one {dep!r} dependency in {path}, replaced {count}" + raise ValueError(msg) + path.write_text(text, encoding="utf-8") + + def parse_core(version: str) -> tuple[int, int, int]: """Parse leading ``X.Y.Z`` from a version string.""" m = CORE_VER_RE.match(version.strip()) @@ -226,6 +265,15 @@ def verify_lockstep() -> int: return 1 raw[str(path.relative_to(REPO_ROOT))] = m.group(2) + for path, deps in WORKSPACE_DEP_PINS.items(): + text = path.read_text(encoding="utf-8") + for dep in deps: + m = re.search(rf'(["\']){re.escape(dep)}==([^"\']+)\1', text) + if not m: + print(f"missing pinned {dep!r} dependency in {path}", file=sys.stderr) + return 1 + raw[f"{path.relative_to(REPO_ROOT)}::{dep}"] = m.group(2) + try: raw["pyproject.toml"] = read_root_pyproject_version() except (KeyError, tomllib.TOMLDecodeError, OSError) as exc: @@ -285,6 +333,7 @@ def main() -> int: return 2 write_version_to_all_files(new_version) + write_dep_pins(new_version) refresh_lockfile() print(f"Bumped {current} -> {new_version}") return 0 diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py index e7043ad9..c8cab735 100644 --- a/tests/test_bump_version.py +++ b/tests/test_bump_version.py @@ -85,3 +85,56 @@ def test_root_project_version_re_rejects_missing_version(pyproject: str) -> None r'\1"REPLACED"', pyproject, count=1 ) assert count == 0, f"expected no match in {pyproject!r}" + + +@pytest.mark.parametrize( + ("entry", "expected"), + [ + # Bare dependency gains a pin. + ('"pipefy-sdk"', '"pipefy-sdk==9.9.9"'), + # Already-pinned dependency is re-pinned to the new version. + ('"pipefy-sdk==0.2.0-beta.3"', '"pipefy-sdk==9.9.9"'), + # Single quotes are handled too. + ("'pipefy-sdk'", "'pipefy-sdk==9.9.9'"), + ], +) +def test_workspace_dep_pin_re_rewrites_dependency(entry: str, expected: str) -> None: + new_text, count = _bump.workspace_dep_pin_re("pipefy-sdk").subn( + r"\g<1>pipefy-sdk==9.9.9\g<2>", entry, count=1 + ) + assert count == 1 + assert new_text == expected + + +@pytest.mark.parametrize( + "text", + [ + # An unquoted [tool.uv.sources] key must not be pinned (no quotes). + "pipefy-sdk = { workspace = true }", + # A parenthesized mention inside a description must not be pinned + # (the name is not the whole quoted string). + 'description = "Typer CLI for Pipefy (pipefy-sdk)."', + ], +) +def test_workspace_dep_pin_re_leaves_non_dependencies_untouched(text: str) -> None: + _new_text, count = _bump.workspace_dep_pin_re("pipefy-sdk").subn( + r"\g<1>pipefy-sdk==9.9.9\g<2>", text, count=1 + ) + assert count == 0, f"unexpectedly matched in {text!r}" + + +def test_workspace_dep_pins_never_target_own_name() -> None: + """The own-name collision (name = "x" is a full quoted string) is avoided by + the pin map never listing a package's own distribution name.""" + own_name = { + "sdk": "pipefy-sdk", + "mcp": "pipefy-mcp-server", + "cli": "pipefy-cli", + "auth": "pipefy-auth", + "infra": "pipefy-infra", + } + for path, deps in _bump.WORKSPACE_DEP_PINS.items(): + pkg_dir = path.parent.name + assert own_name[pkg_dir] not in deps, ( + f"{path} would pin its own name {own_name[pkg_dir]!r}" + )