Skip to content

Commit f62a9b4

Browse files
committed
feat(cli): Add upgrade to fetch new releases of existing packages, and sort to sort versions oldest to latest.
1 parent 5e959a5 commit f62a9b4

3 files changed

Lines changed: 222 additions & 3 deletions

File tree

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pip install -e .
3939

4040
#### CLI Reference
4141

42-
`scripts/sync_nuget.py` is a CLI with three commands:
42+
`scripts/sync_nuget.py` is a CLI with five commands:
4343

4444
**`sync`** — Download and upload packages defined in a packages file. Skips versions already published to the GitHub Package Registry.
4545

@@ -69,6 +69,30 @@ python scripts/sync_nuget.py register packages.yml Keyfactor.PKI 8.4.0 --skip-va
6969

7070
After registering, run `sync` to push the new version(s) to the GitHub Package Registry.
7171

72+
**`upgrade`** — Query Azure DevOps for new versions of all packages already listed in a packages file and register them automatically.
73+
74+
```bash
75+
# Check all packages for new stable versions and register them
76+
source .env && python scripts/sync_nuget.py upgrade packages.yml
77+
78+
# Preview what would be registered without writing
79+
source .env && python scripts/sync_nuget.py upgrade packages.yml --dry-run
80+
81+
# Upgrade a single package
82+
source .env && python scripts/sync_nuget.py upgrade packages.yml --package Keyfactor.PKI
83+
84+
# Include prerelease versions
85+
source .env && python scripts/sync_nuget.py upgrade packages.yml --include-prerelease
86+
```
87+
88+
After upgrading, run `sync` to push the new versions to the GitHub Package Registry.
89+
90+
**`sort`** — Sort versions for all packages in a packages file into ascending semver order and remove any duplicates.
91+
92+
```bash
93+
python scripts/sync_nuget.py sort packages.yml
94+
```
95+
7296
**`download`** — Download a single package version from Azure DevOps without uploading it.
7397

7498
```bash

packages.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ packages:
2727
- 1.0.0
2828
- name: Keyfactor.PKI
2929
versions:
30-
- 4.0.1
3130
- 3.2.0
3231
- 3.4.7
3332
- 3.4.8
33+
- 4.0.1
3434
- 5.0.0
3535
- 5.5.0
3636
- 5.7.0
@@ -54,6 +54,8 @@ packages:
5454
- 2.8.1
5555
- 2.8.2
5656
- 2.9.0
57+
- 2.10.0
58+
- 2.11.0
5759
- name: Keyfactor.AnyGateway.IAnyCAPlugin
5860
versions:
5961
- 2.0.0

scripts/sync_nuget.py

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
Commands:
44
sync Download and upload all (or one) package(s) defined in a packages YAML file.
55
register Add a new package or version(s) to a packages YAML file.
6+
upgrade Query Azure DevOps for new versions of all listed packages and register them.
7+
sort Sort and deduplicate versions in a packages YAML file.
68
download Download a single package version directly from Azure DevOps.
79
810
Environment variables:
@@ -307,6 +309,119 @@ def sync_packages(self) -> None:
307309
# Helpers used by the register command
308310
# ---------------------------------------------------------------------------
309311

312+
def _version_key(v: str) -> tuple[int, ...]:
313+
"""Return a tuple of ints for semver-aware sorting.
314+
315+
Non-numeric segments (e.g. prerelease labels) fall back to zero so they
316+
sort below their numeric counterparts.
317+
318+
Args:
319+
v: A version string such as ``8.2.3`` or ``1.0.0``.
320+
321+
Returns:
322+
A tuple of integers suitable for use as a sort key.
323+
"""
324+
parts: list[int] = []
325+
for segment in v.split("."):
326+
try:
327+
parts.append(int(segment))
328+
except ValueError:
329+
parts.append(0)
330+
return tuple(parts)
331+
332+
333+
def _sort_versions_in_file(packages_file: str) -> dict[str, int]:
334+
"""Sort the versions for every package in *packages_file* in ascending semver order.
335+
336+
Edits the file in-place using line-based manipulation so that comments and
337+
overall formatting are preserved. Inline comments on version lines (e.g.
338+
``- 1.0.0.1 # unusual release``) travel with their version entry.
339+
340+
Args:
341+
packages_file: Path to the packages YAML file.
342+
343+
Returns:
344+
A mapping of ``{package_name: version_count}`` for each package whose
345+
versions were reordered. Packages that were already sorted are omitted.
346+
"""
347+
with open(packages_file, "r") as f:
348+
lines = f.readlines()
349+
350+
changed: dict[str, int] = {}
351+
current_pkg: Optional[str] = None
352+
in_versions: bool = False
353+
ver_indices: list[int] = []
354+
355+
def _flush() -> None:
356+
nonlocal ver_indices, in_versions
357+
if ver_indices and current_pkg is not None:
358+
def _ver_str(idx: int) -> str:
359+
raw = lines[idx].strip()
360+
return raw[2:].split("#")[0].strip() if raw.startswith("- ") else raw
361+
362+
# Deduplicate (first occurrence wins) then sort
363+
unique: dict[str, str] = {} # version_str -> original line content
364+
for idx in ver_indices:
365+
v = _ver_str(idx)
366+
if v not in unique:
367+
unique[v] = lines[idx]
368+
369+
sorted_lines = [unique[v] for v in sorted(unique, key=_version_key)]
370+
original_lines = [lines[i] for i in ver_indices]
371+
372+
# Write sorted lines back; blank any slots freed by deduplication
373+
for pos, idx in enumerate(ver_indices):
374+
lines[idx] = sorted_lines[pos] if pos < len(sorted_lines) else ""
375+
376+
if [lines[i] for i in ver_indices] != original_lines:
377+
changed[current_pkg] = len(sorted_lines)
378+
ver_indices.clear()
379+
in_versions = False
380+
381+
for i, line in enumerate(lines):
382+
stripped = line.strip()
383+
if stripped.startswith("# "):
384+
continue
385+
if stripped.startswith("- name:") or (not stripped.startswith("-") and stripped.startswith("name:")):
386+
_flush()
387+
current_pkg = stripped.split("name:", 1)[1].strip()
388+
elif stripped == "versions:" and current_pkg is not None:
389+
in_versions = True
390+
elif in_versions:
391+
if stripped.startswith("- ") and not stripped.startswith("- name:"):
392+
ver_indices.append(i)
393+
elif stripped and not stripped.startswith("#"):
394+
_flush()
395+
396+
_flush()
397+
398+
with open(packages_file, "w") as f:
399+
f.writelines(lines)
400+
401+
return changed
402+
403+
404+
def _get_azdo_versions(name: str, az_pat: str) -> set[str]:
405+
"""Return all versions available for *name* in the Azure DevOps feed.
406+
407+
Args:
408+
name: The NuGet package ID.
409+
az_pat: Azure DevOps PAT used for authentication.
410+
411+
Returns:
412+
A set of version strings available in the feed, or an empty set if the
413+
package is not found or the request fails.
414+
"""
415+
resp = requests.get(
416+
f"{_AZDO_FEED_BASE}/{name.lower()}/index.json",
417+
auth=("any", az_pat),
418+
timeout=15,
419+
)
420+
if resp.status_code != 200:
421+
return set()
422+
return set(resp.json().get("versions", []))
423+
424+
310425
def _validate_versions(name: str, versions: tuple[str, ...], az_pat: str) -> None:
311426
"""Verify that each requested version exists in the Azure DevOps feed.
312427
@@ -372,7 +487,7 @@ def _write_versions_to_file(
372487
existing = next((p for p in packages if p.get('name', '').lower() == name.lower()), None)
373488

374489
already_present: set[str] = {str(v) for v in existing.get('versions', [])} if existing else set()
375-
to_add = [v for v in versions if v not in already_present]
490+
to_add = sorted([v for v in versions if v not in already_present], key=_version_key)
376491
skipped = [v for v in versions if v in already_present]
377492

378493
if not to_add:
@@ -483,6 +598,84 @@ def register(
483598
click.echo("Nothing to register.")
484599

485600

601+
@cli.command()
602+
@click.argument("packages_file", type=click.Path(exists=True, dir_okay=False))
603+
@click.option("--package", default=None,
604+
help="Check only this package name (must exist in the packages file).")
605+
@click.option("--dry-run", is_flag=True, default=False,
606+
help="Print new versions that would be registered without writing to the file.")
607+
@click.option("--include-prerelease", is_flag=True, default=False,
608+
help="Include prerelease versions (excluded by default).")
609+
def upgrade(packages_file: str, package: Optional[str], dry_run: bool, include_prerelease: bool) -> None:
610+
"""Check Azure DevOps for new versions of packages listed in PACKAGES_FILE and register them.
611+
612+
Stable versions only by default; use --include-prerelease to also pick up
613+
prerelease versions. Requires AZ_DEVOPS_PAT env var.
614+
615+
After running upgrade, use the sync command to push the new versions to the
616+
GitHub Package Registry.
617+
"""
618+
az_pat = os.getenv("AZ_DEVOPS_PAT")
619+
if not az_pat:
620+
raise click.ClickException("AZ_DEVOPS_PAT env var is required.")
621+
622+
with open(packages_file, "r") as f:
623+
data = yaml.safe_load(f)
624+
packages: list[dict] = data.get("packages") or []
625+
626+
if package:
627+
packages = [p for p in packages if p.get("name", "").lower() == package.lower()]
628+
if not packages:
629+
raise click.BadParameter(f"Package '{package}' not found in {packages_file}.")
630+
631+
total_added: list[tuple[str, str]] = []
632+
633+
for pkg in packages:
634+
name: str = pkg.get("name", "")
635+
current: set[str] = {str(v) for v in pkg.get("versions") or []}
636+
637+
click.echo(f"Checking {name}...")
638+
available = _get_azdo_versions(name, az_pat)
639+
if not available:
640+
click.echo(" Not found in Azure DevOps feed — skipping.")
641+
continue
642+
643+
candidates = available if include_prerelease else {v for v in available if "PRERELEASE" not in v.upper()}
644+
max_current = max(current, key=_version_key) if current else None
645+
new_versions = sorted(
646+
(v for v in candidates - current if max_current is None or _version_key(v) > _version_key(max_current)),
647+
key=_version_key,
648+
)
649+
if not new_versions:
650+
click.echo(f" Up to date.")
651+
continue
652+
653+
click.echo(f" New versions: {', '.join(new_versions)}")
654+
if not dry_run:
655+
_write_versions_to_file(packages_file, name, tuple(new_versions))
656+
for v in new_versions:
657+
total_added.append((name, v))
658+
659+
if dry_run:
660+
click.echo("\n(Dry run — no changes written.)")
661+
else:
662+
click.echo(f"\nUpgrade complete. {len(total_added)} new version(s) registered.")
663+
if total_added:
664+
click.echo("Run 'sync' to push them to the GitHub Package Registry.")
665+
666+
667+
@cli.command(name="sort")
668+
@click.argument("packages_file", type=click.Path(exists=True, dir_okay=False))
669+
def sort_cmd(packages_file: str) -> None:
670+
"""Sort versions for all packages in PACKAGES_FILE in ascending semver order."""
671+
changed = _sort_versions_in_file(packages_file)
672+
if changed:
673+
for name, count in changed.items():
674+
click.echo(f"Sorted {count} version(s) for {name}")
675+
else:
676+
click.echo("All versions already in order.")
677+
678+
486679
@cli.command()
487680
@click.argument("name")
488681
@click.argument("version")

0 commit comments

Comments
 (0)