Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/sync-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ scripts:

# Dev dependency sync script
- source: scripts/sync_dev_dependencies.py
description: "Syncs dev dependency versions from autofix-versions.env to pyproject.toml"
description: "Syncs canonical dev-tool pins and safely regenerates transitive uv requirements locks with their recorded scope"
template_sync: exact
- source: scripts/check_agents_md_freshness.py
description: "Warns when the generated Orchestrator AGENTS.md playbook section cites stale repo paths or commands"
template_sync: exact
Expand Down
33 changes: 18 additions & 15 deletions .github/workflows/maint-52-sync-dev-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,22 +218,25 @@ jobs:
| tee /tmp/sync_output.txt; then
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 --pre-commit
if [ -f uv.lock ]; then
uv lock
fi
fi
fi

# Resolve even when direct pins already match: transitive drift is
# invisible to the direct-pin check and can still break clean CI.
# Dry runs resolve in this disposable checkout too; publication is
# guarded separately. Only the final tree diff means an update exists.
python -m pip install --disable-pip-version-check 'uv>=0.5'
python ../scripts/sync_dev_dependencies.py --apply --pre-commit --resolve-locks
if [ -f uv.lock ]; then uv lock; fi
if ! git diff --quiet; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
fi

# Save output for PR body
Expand All @@ -254,21 +257,21 @@ jobs:

echo "Adding dev dependencies section to pyproject.toml..."

python -m pip install --disable-pip-version-check 'uv>=0.5'

# Use --create-if-missing to add dev deps
if python ../scripts/sync_dev_dependencies.py \
--apply \
--pre-commit \
--resolve-locks \
--create-if-missing 2>&1 \
| tee /tmp/sync_output.txt; then
if grep -q "version updates" /tmp/sync_output.txt; then
if [ -f uv.lock ]; then uv lock; fi
if ! git diff --quiet; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
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
exit 1
fi
Expand Down
20 changes: 20 additions & 0 deletions docs/ops/CONSUMER_REPO_MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,26 @@ direct `requirements.lock` pins must move in the same Workflows PR. Maint 52
also updates managed `.pre-commit-config.yaml` hook revisions and direct tool pins in a consumer's `requirements-dev.lock` when that
additional generated lockfile exists.

Direct pin replacement is not lock regeneration. Maint 52 applies
`sync_dev_dependencies.py --apply --pre-commit --resolve-locks` before publishing
a changed dependency tree. For generated uv requirements locks, this replays the
recorded repository-local compile inputs and supported extras/platform options
as an argument vector (never a shell command), permitting upgrades of managed
tools and resolving their changed transitive requirements. Existing unrelated
output pins remain resolver preferences. Unsupported provenance or a solver
failure stops publication with a concrete error; it must not leave a knowingly
unsatisfiable generated delivery marked ready to merge. Manually authored
`requirements-dev.txt` without uv provenance remains a direct-pin surface.
Repository-local baseline `.txt` inputs and uv's plural `--constraints` and
`--overrides` options retain their scope. Hashed requirement continuations still
receive canonical tool constraints; previously recorded managed upgrade options
are replaced by the current canonical version. Escaped input/output destinations
fail closed. A successful unchanged lock is a no-op, not an update receipt.
Both apply branches derive publication readiness from the final tree diff; dry
runs perform the same resolution in the disposable checkout without publishing.
The propagation-script digest participates in the wave hash, so this repair
produces a replacement wave even when the canonical tool versions are unchanged.

Consumer repos receive those pins through `maint-52-sync-dev-versions.yml`, not
the general `maint-68-sync-consumer-repos.yml` template sync. Keep
`.github/workflows/autofix-versions.env` out of `.github/sync-manifest.yml` so a
Expand Down
134 changes: 133 additions & 1 deletion scripts/sync_dev_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

import argparse
import re
import shlex
import subprocess
import sys
from pathlib import Path
from urllib.parse import urlparse
Expand Down Expand Up @@ -352,6 +354,123 @@ def _build_lockfile_targets(pins: dict[str, str]) -> dict[str, str]:
return targets


def regenerate_lockfile(lockfile_path: Path, pins: dict[str, str]) -> tuple[list[str], list[str]]:
"""Resolve a generated lock using its recorded inputs, never shell replay.

Direct pin rewriting is not sufficient when a tool adds or tightens a
transitive requirement. Preserve the consumer's extras/platform choices
and existing output preferences, permitting managed tools to upgrade.
Unsupported provenance fails closed instead of guessing a lock scope.
"""
if not lockfile_path.exists():
return [], []
content = lockfile_path.read_text(encoding="utf-8")
command = next(
(
line[1:].strip()
for line in content.splitlines()
if line.startswith("#") and line[1:].strip().startswith("uv pip compile ")
),
"",
)
# requirements-dev.txt may be a manually authored direct requirement list.
if not command and lockfile_path.suffix == ".txt":
return [], []
try:
tokens = shlex.split(command)
if tokens[:3] != ["uv", "pip", "compile"]:
raise ValueError("missing supported uv compile provenance")
value_flags = {
"--extra",
"--group",
"--python-version",
"--python-platform",
"--no-emit-package",
"--constraints",
"-c",
"--overrides",
"--resolution",
"--exclude-newer",
"--output-file",
"-o",
"--upgrade-package",
"-P",
}
bool_flags = {
"--universal",
"--all-extras",
"--generate-hashes",
"--no-strip-extras",
"--no-strip-markers",
"--no-annotate",
"--emit-index-url",
"--emit-find-links",
}
output = None
sources = []
canonical = _build_lockfile_targets(pins)
remove_indices = set()
upgrade_names = set()
i = 3
while i < len(tokens):
start = i
token = tokens[i]
flag, separator, value = token.partition("=")
if flag in value_flags:
if not separator:
i += 1
if i >= len(tokens):
raise ValueError("missing compile option value")
value = tokens[i]
if not value or value.startswith("-"):
raise ValueError("invalid compile option value")
if flag in {"--output-file", "-o"}:
if output is not None:
raise ValueError("duplicate output destination")
output = value
if flag in {"--constraints", "-c", "--overrides"}:
sources.append(value)
if flag in {"--upgrade-package", "-P"}:
package = re.match(r"[A-Za-z0-9_.-]+", value)
if package is None:
raise ValueError("invalid package upgrade")
name = package.group().lower()
if name in canonical:
upgrade_names.add(name)
remove_indices.update(range(start, i + 1))
elif token in bool_flags:
pass
elif token == "pyproject.toml" or token.endswith((".in", ".txt")):
sources.append(token)
else:
raise ValueError("unsupported compile argument")
i += 1
if output != str(lockfile_path) or not sources:
raise ValueError("compile output or inputs do not match this lock")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
root = Path.cwd().resolve()
if lockfile_path.is_absolute() or not lockfile_path.resolve().is_relative_to(root):
raise ValueError("compile output must remain repository-local")
for source in sources:
path = Path(source)
if path.is_absolute() or not path.resolve().is_relative_to(root) or not path.is_file():
raise ValueError("compile input must be an existing repository-local file")
present = {
match.group("name").lower()
for line in content.splitlines()
if (match := LOCKFILE_PATTERN.match(line.rstrip().removesuffix("\\").rstrip()))
}
tokens = [token for index, token in enumerate(tokens) if index not in remove_indices]
for name, version in canonical.items():
if name in present or name in upgrade_names:
tokens.extend(["--upgrade-package", f"{name}=={version}"])
subprocess.run(tokens, check=True)
if lockfile_path.read_text(encoding="utf-8") == content:
return [], []
except (ValueError, OSError, subprocess.CalledProcessError) as exc:
return [], [f"{lockfile_path}: transitive lock regeneration failed ({exc})"]
return [f"{lockfile_path}: regenerated transitive dependencies"], []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_lockfile(
lockfile_path: Path, pins: dict[str, str], apply: bool = False
) -> tuple[list[str], list[str]]:
Expand Down Expand Up @@ -413,7 +532,7 @@ def _pre_commit_repo_name(line: str) -> str | None:
repo = parsed.path.lstrip("/")
repo = repo.rstrip("/").removesuffix(".git")
if repo.lower().startswith("github.com/"):
repo = repo.split("/", 1)[1]
repo = repo[len("github.com/") :]
return repo.lower()


Expand Down Expand Up @@ -500,6 +619,11 @@ def main(argv: list[str] | None = None) -> int:
action="store_true",
help="Apply version updates to pyproject.toml and supported requirements lockfiles",
)
parser.add_argument(
"--resolve-locks",
action="store_true",
help="After applying pins, regenerate uv requirements locks with their recorded scope",
)
parser.add_argument(
"--create-if-missing",
action="store_true",
Expand Down Expand Up @@ -540,6 +664,8 @@ def main(argv: list[str] | None = None) -> int:

if args.check and args.apply:
parser.error("--check and --apply are mutually exclusive")
if args.resolve_locks and not args.apply:
parser.error("--resolve-locks requires --apply")

if not args.check and not args.apply:
args.check = True # Default to check mode
Expand Down Expand Up @@ -571,6 +697,12 @@ def main(argv: list[str] | None = None) -> int:
changes.extend(pre_commit_changes)
errors.extend(pre_commit_errors)

if args.resolve_locks and not errors:
for lockfile_path in LOCKFILE_FILES:
lock_changes, lock_errors = regenerate_lockfile(lockfile_path, pins)
changes.extend(lock_changes)
errors.extend(lock_errors)

if errors:
for err in errors:
print(f"Error: {err}", file=sys.stderr)
Expand Down
Loading
Loading