diff --git a/.github/workflows/maint-52-sync-dev-versions.yml b/.github/workflows/maint-52-sync-dev-versions.yml index 0882ae11e..4039dd6f4 100644 --- a/.github/workflows/maint-52-sync-dev-versions.yml +++ b/.github/workflows/maint-52-sync-dev-versions.yml @@ -183,24 +183,42 @@ jobs: id: sync if: steps.check.outputs.has_dev_deps == 'true' run: | - set -o pipefail # Ensure pipeline returns first failing command's exit code + set -euo pipefail # Copy pin file to expected location mkdir -p consumer/.github/workflows cp .github/workflows/autofix-versions.env consumer/.github/workflows/ cd consumer + uv_lock_stale=false + if [ -f uv.lock ]; then + python -m pip install --disable-pip-version-check 'uv>=0.5' + if ! uv lock --check; then + uv_lock_stale=true + fi + fi + # Run sync check first if python ../scripts/sync_dev_dependencies.py \ --check 2>&1 \ | tee /tmp/sync_output.txt; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" + if [ "$uv_lock_stale" = "true" ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + if [ "${{ inputs.dry_run }}" != "true" ]; then + uv lock + fi + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi else echo "has_changes=true" >> "$GITHUB_OUTPUT" # Apply if not dry run if [ "${{ inputs.dry_run }}" != "true" ]; then python ../scripts/sync_dev_dependencies.py --apply + if [ -f uv.lock ]; then + uv lock + fi fi fi @@ -213,7 +231,7 @@ jobs: steps.check.outputs.has_pyproject == 'true' && steps.check.outputs.has_dev_deps != 'true' run: | - set -o pipefail + set -euo pipefail # Copy pin file to expected location mkdir -p consumer/.github/workflows cp .github/workflows/autofix-versions.env consumer/.github/workflows/ @@ -232,8 +250,12 @@ jobs: else echo "has_changes=false" >> "$GITHUB_OUTPUT" fi + if [ "${{ inputs.dry_run }}" != "true" ] && [ -f uv.lock ]; then + python -m pip install --disable-pip-version-check 'uv>=0.5' + uv lock + fi else - echo "has_changes=true" >> "$GITHUB_OUTPUT" + exit 1 fi # Save output for PR body @@ -277,6 +299,8 @@ jobs: git add pyproject.toml .github/workflows/autofix-versions.env if [ -f requirements.lock ]; then git add requirements.lock; fi if [ -f requirements-dev.lock ]; then git add requirements-dev.lock; fi + if [ -f requirements-dev.txt ]; then git add requirements-dev.txt; fi + if [ -f uv.lock ]; then git add uv.lock; fi # Commit with multi-line message commit_msg="deps: sync dev tool versions from Workflows diff --git a/scripts/sync_dev_dependencies.py b/scripts/sync_dev_dependencies.py index cadd2d7e0..f114718a3 100755 --- a/scripts/sync_dev_dependencies.py +++ b/scripts/sync_dev_dependencies.py @@ -26,7 +26,13 @@ # Default paths (can be overridden for testing) PIN_FILE = Path(".github/workflows/autofix-versions.env") PYPROJECT_FILE = Path("pyproject.toml") -LOCKFILE_FILES = (Path("requirements.lock"), Path("requirements-dev.lock")) +# Direct requirement files can be installed by CI independently of pyproject. +# Keep every supported dev-tool surface aligned with the canonical pin file. +LOCKFILE_FILES = ( + Path("requirements.lock"), + Path("requirements-dev.lock"), + Path("requirements-dev.txt"), +) # Map env file keys to package names # Format: ENV_KEY -> (package_name, optional_alternative_names) @@ -53,7 +59,9 @@ ] LOCKFILE_PATTERN = re.compile( - r"^(?P\s*)(?P[A-Za-z0-9_.-]+)==(?P[^\s#]+)(?P\s*(?:#.*)?)$" + r"^(?P\s*)(?P[A-Za-z0-9_.-]+)(?P\[[^]]+\])?" + r"(?P(?:===|==|!=|<=|>=|~=|<|>)[^\s;#]+)?" + r"(?P\s*;[^#]+?)?(?P\s*(?:#.*)?)$" ) @@ -335,13 +343,20 @@ def sync_lockfile( continue name = match.group("name") - version = match.group("version") target_version = targets.get(name.lower()) - if target_version and version != target_version: - changes.append(f"{lockfile_path.name}:{name}: {version} -> =={target_version}") + current_requirement = f"{match.group('specifier') or ''}{match.group('marker') or ''}" + target_requirement = f"=={target_version}{match.group('marker') or ''}" + if target_version and current_requirement != target_requirement: + current_version = match.group("specifier") or "(unversioned)" + if current_version.startswith("=="): + current_version = current_version[2:] + changes.append( + f"{lockfile_path.name}:{name}: " f"{current_version} -> =={target_version}" + ) if apply: updated_lines.append( - f"{match.group('lead')}{name}=={target_version}{match.group('trail')}" + f"{match.group('lead')}{name}{match.group('extras') or ''}" + f"=={target_version}{match.group('marker') or ''}{match.group('trail')}" ) else: updated_lines.append(line) diff --git a/tests/scripts/test_sync_dev_dependencies.py b/tests/scripts/test_sync_dev_dependencies.py index a94c6c63a..51890d41d 100644 --- a/tests/scripts/test_sync_dev_dependencies.py +++ b/tests/scripts/test_sync_dev_dependencies.py @@ -101,6 +101,30 @@ def test_sync_lockfile_preserves_comments_and_whitespace(tmp_path: Path) -> None assert "black==2.0.0" in updated +def test_sync_lockfile_normalizes_non_exact_and_unversioned_requirements(tmp_path: Path) -> None: + lockfile = tmp_path / "requirements-dev.txt" + lockfile.write_text( + "black>=0.2.0\nruff\nmypy[reports]~=1.2.0 ; python_version >= '3.10'\n", + encoding="utf-8", + ) + + changes, errors = sdd.sync_lockfile( + lockfile, + {"BLACK_VERSION": "2.0.0", "RUFF_VERSION": "1.0.0", "MYPY_VERSION": "1.3.0"}, + apply=True, + ) + + assert errors == [] + assert changes == [ + "requirements-dev.txt:black: >=0.2.0 -> ==2.0.0", + "requirements-dev.txt:ruff: (unversioned) -> ==1.0.0", + "requirements-dev.txt:mypy: ~=1.2.0 -> ==1.3.0", + ] + assert lockfile.read_text(encoding="utf-8") == ( + "black==2.0.0\nruff==1.0.0\n" "mypy[reports]==1.3.0 ; python_version >= '3.10'\n" + ) + + def test_sync_pyproject_normalizes_minimum_pin_at_target_version( tmp_path: Path, ) -> None: @@ -158,12 +182,14 @@ def test_main_apply_updates_all_present_requirements_lockfiles( pyproject_path = tmp_path / "pyproject.toml" requirements_lock = tmp_path / "requirements.lock" requirements_dev_lock = tmp_path / "requirements-dev.lock" + requirements_dev_txt = tmp_path / "requirements-dev.txt" pins = {"RUFF_VERSION": "1.0.0", "BLACK_VERSION": "2.0.0"} _write_env_file(env_path, pins) _write_pyproject(pyproject_path, "0.9.0", "2.0.0") requirements_lock.write_text("ruff==0.9.0\n", encoding="utf-8") requirements_dev_lock.write_text("ruff==0.8.0\n", encoding="utf-8") + requirements_dev_txt.write_text("ruff==0.7.0\n", encoding="utf-8") monkeypatch.chdir(tmp_path) exit_code = sdd.main( @@ -179,6 +205,7 @@ def test_main_apply_updates_all_present_requirements_lockfiles( assert exit_code == 0 assert requirements_lock.read_text(encoding="utf-8") == "ruff==1.0.0\n" assert requirements_dev_lock.read_text(encoding="utf-8") == "ruff==1.0.0\n" + assert requirements_dev_txt.read_text(encoding="utf-8") == "ruff==1.0.0\n" def test_main_check_reports_lockfile_mismatch( diff --git a/tests/workflows/test_maint52_sync_dev_versions_pr_body.py b/tests/workflows/test_maint52_sync_dev_versions_pr_body.py index fa11faeb1..3d643f88a 100644 --- a/tests/workflows/test_maint52_sync_dev_versions_pr_body.py +++ b/tests/workflows/test_maint52_sync_dev_versions_pr_body.py @@ -29,3 +29,11 @@ def test_wave_hash_includes_the_sync_implementation(): assert "scripts/sync_dev_dependencies.py" in hash_block assert "| sha256sum" in hash_block assert "| cut -d' ' -f1" in hash_block + + +def test_dev_version_sync_fails_fast_and_checks_uv_lockfiles(): + text = WORKFLOW.read_text(encoding="utf-8") + + assert text.count("set -euo pipefail") >= 2 + assert "if ! uv lock --check; then" in text + assert "uv_lock_stale=true" in text