Skip to content

Commit c106bcc

Browse files
committed
feat(cli): Convert sync script to lightweight CLI.
1 parent 3c5a2db commit c106bcc

5 files changed

Lines changed: 197 additions & 34 deletions

File tree

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ name: Build
33
on:
44
push:
55

6+
permissions:
7+
contents: write
8+
issues: write
9+
pull-requests: write
10+
611
jobs:
712
release:
813
runs-on: ubuntu-latest

.github/workflows/sync-nuget.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ on:
77
workflow_dispatch:
88
# Allow manual triggering
99

10+
permissions:
11+
contents: read
12+
packages: write
13+
1014
jobs:
1115
sync-packages:
1216
runs-on: ubuntu-latest
@@ -55,7 +59,7 @@ jobs:
5559
pip install -e .
5660
5761
- name: Run NuGet sync script
58-
run: python scripts/sync_nuget.py
62+
run: python scripts/sync_nuget.py sync packages.yml
5963
env:
6064
GITHUB_TOKEN: ${{ secrets.GH_NUGET_TOKEN }}
6165
AZ_DEVOPS_PAT: ${{ secrets.AZ_DEVOPS_PAT }}

README.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,44 @@ export GITHUB_TOKEN=<GitHub PAT with write:packages permission>
3131
export GITHUB_ORG=keyfactor
3232
```
3333

34-
2. Install dependencies and run:
34+
2. Install dependencies:
3535

3636
```bash
3737
python -m venv venv && source venv/bin/activate
3838
pip install -e .
39-
source .env && python scripts/sync_nuget.py
4039
```
4140

42-
The script queries the GitHub Package Registry first and skips any versions already published.
41+
#### CLI Reference
42+
43+
The sync script is a CLI with two commands:
44+
45+
**`sync`** — Download and upload packages defined in a packages file. Skips versions already published to the GitHub Package Registry.
46+
47+
```bash
48+
# Sync all packages
49+
source .env && python scripts/sync_nuget.py sync packages.yml
50+
51+
# Sync a single package
52+
source .env && python scripts/sync_nuget.py sync packages.yml --package Keyfactor.PKI
53+
```
54+
55+
**`register`** — Add a package and version(s) to a packages file. Validates that each version exists in the Azure DevOps feed before writing.
56+
57+
```bash
58+
# Register a new version of an existing package
59+
source .env && python scripts/sync_nuget.py register packages.yml Keyfactor.PKI 8.4.0
60+
61+
# Register multiple versions at once
62+
source .env && python scripts/sync_nuget.py register packages.yml Keyfactor.PKI 8.4.0 8.5.0
63+
64+
# Register a brand new package
65+
source .env && python scripts/sync_nuget.py register packages.yml Keyfactor.NewPackage 1.0.0
66+
67+
# Skip Azure DevOps feed validation
68+
python scripts/sync_nuget.py register packages.yml Keyfactor.PKI 8.4.0 --skip-validate
69+
```
70+
71+
After registering, run `sync` to push the new version(s) to the GitHub Package Registry.
4372

4473
### Manually using dotnet CLI
4574

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ readme = "README.md"
1313
requires-python = ">=3.8"
1414
license = {file = "LICENSE"}
1515
dependencies = [
16+
"click>=8.0",
1617
"pyyaml>=6.0",
1718
"requests>=2.28"
1819
]

scripts/sync_nuget.py

Lines changed: 154 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,35 @@
22
import shutil
33
import subprocess
44
import tempfile
5+
import click
56
import requests
67
import yaml
78

89
class NuGetSyncer:
9-
def __init__(self):
10+
def __init__(self, packages_file, package_filter=None):
1011
self.NUGET_FEED_URL = "https://pkgs.dev.azure.com/Keyfactor/_packaging/KeyfactorPackages/nuget/v3/index.json"
1112
self.GITHUB_NUGET_URL = "https://nuget.pkg.github.com/keyfactor/index.json"
1213
self.GITHUB_TOKEN = os.getenv("GH_NUGET_TOKEN", os.getenv("GITHUB_TOKEN"))
1314
self.AZ_DEVOPS_PAT = os.getenv("AZ_DEVOPS_PAT")
1415
self.TMP_DIR = "nupkgs"
15-
self.PACKAGES_YML = "packages.yml"
1616
self.GITHUB_NUGET_BASE = "https://nuget.pkg.github.com/keyfactor"
17-
self.allowed_packages = self.load_allowed_packages()
17+
self.package_filter = package_filter
18+
self.allowed_packages = self.load_allowed_packages(packages_file)
1819
self._github_versions_cache = {}
1920
os.makedirs(self.TMP_DIR, exist_ok=True)
2021

21-
def load_allowed_packages(self):
22+
def load_allowed_packages(self, packages_file):
2223
try:
23-
with open(self.PACKAGES_YML, 'r') as file:
24-
yaml_data = yaml.safe_load(file)
25-
return yaml_data.get('packages', [])
24+
with open(packages_file, 'r') as f:
25+
packages = yaml.safe_load(f).get('packages', []) or []
2626
except Exception as e:
27-
print(f"Error loading packages.yml: {e}")
28-
return set()
29-
30-
def get_all_packages_and_versions(self):
31-
# This function should be implemented to get all allowed packages and their versions
32-
# For now, let's assume you have a list of versions for each package in packages.yml
33-
# You can extend this to read from example_versions.json or another source
34-
# Example: { 'PackageA': ['1.0.0', '2.0.0'], ... }
35-
# For demonstration, we'll just return the allowed packages with no versions
36-
if isinstance(self.allowed_packages, str):
37-
return f"{self.allowed_packages}".split(",")
38-
elif isinstance(self.allowed_packages, list) or isinstance(self.allowed_packages, set):
39-
return self.allowed_packages
40-
return {pkg: [] for pkg in self.allowed_packages}
27+
click.echo(f"Error loading {packages_file}: {e}", err=True)
28+
return []
29+
if self.package_filter:
30+
packages = [p for p in packages if p.get('name', '').lower() == self.package_filter.lower()]
31+
if not packages:
32+
raise click.BadParameter(f"Package '{self.package_filter}' not found in {packages_file}")
33+
return packages
4134

4235
def get_github_published_versions(self, name):
4336
"""Fetch the list of versions already published to GitHub Packages for a given package."""
@@ -176,14 +169,13 @@ def upload_all_packages_to_github(self):
176169

177170
def sync_packages(self):
178171
if not self.allowed_packages:
179-
print("No packages specified in packages.yml. Nothing to sync.")
172+
click.echo("No packages specified. Nothing to sync.")
180173
return
181-
print(f"Will sync the following packages: {self.allowed_packages}")
182-
packages_and_versions = self.get_all_packages_and_versions()
174+
click.echo(f"Will sync the following packages: {[p.get('name', p) for p in self.allowed_packages]}")
183175
skipped = 0
184176
successful = 0
185177
failed = 0
186-
for pkg in packages_and_versions:
178+
for pkg in self.allowed_packages:
187179
pkg_name = pkg.get('name', pkg)
188180
versions = pkg.get('versions', [])
189181
published = self.get_github_published_versions(pkg_name)
@@ -216,11 +208,143 @@ def sync_packages(self):
216208
print(f" Skipped: {skipped}")
217209
print(f" Failed: {failed}")
218210

219-
if __name__ == "__main__":
220-
syncer = NuGetSyncer()
211+
AZDO_FEED_BASE = "https://pkgs.dev.azure.com/Keyfactor/_packaging/KeyfactorPackages/nuget/v3/flat2"
212+
213+
214+
def _validate_versions(name, versions, az_pat):
215+
"""Check that each version exists in the Azure DevOps feed."""
216+
resp = requests.get(
217+
f"{AZDO_FEED_BASE}/{name.lower()}/index.json",
218+
auth=("any", az_pat),
219+
timeout=15,
220+
)
221+
if resp.status_code != 200:
222+
raise click.ClickException(f"Package '{name}' not found in Azure DevOps feed.")
223+
available = set(resp.json().get("versions", []))
224+
missing = [v for v in versions if v not in available]
225+
if missing:
226+
raise click.ClickException(
227+
f"Version(s) not found in Azure DevOps feed: {', '.join(missing)}\n"
228+
f"Available: {', '.join(sorted(available))}"
229+
)
230+
231+
232+
def _write_versions_to_file(packages_file, name, versions):
233+
"""
234+
Insert versions into packages_file using line-based editing to preserve
235+
all comments and formatting. Returns (added, skipped) version lists.
236+
"""
237+
with open(packages_file, 'r') as f:
238+
lines = f.readlines()
239+
240+
# Parse current state to know which versions already exist
241+
with open(packages_file, 'r') as f:
242+
data = yaml.safe_load(f)
243+
packages = data.get('packages') or []
244+
existing = next((p for p in packages if p.get('name', '').lower() == name.lower()), None)
245+
246+
already_present = {str(v) for v in existing.get('versions', [])} if existing else set()
247+
to_add = [v for v in versions if v not in already_present]
248+
skipped = [v for v in versions if v in already_present]
249+
250+
if not to_add:
251+
return [], skipped
252+
253+
if existing:
254+
# Find the last version line for this package and insert after it.
255+
# Locate the `- name: <name>` line first.
256+
pkg_line = next(
257+
(i for i, l in enumerate(lines) if l.strip().lstrip('- ').startswith(f'name: {name}')),
258+
None,
259+
)
260+
if pkg_line is None:
261+
raise click.ClickException(f"Could not locate '{name}' in {packages_file}.")
262+
263+
# Walk forward to find the last `- <version>` line inside this package block.
264+
last_ver_line = None
265+
ver_indent = None
266+
in_versions = False
267+
for i in range(pkg_line + 1, len(lines)):
268+
stripped = lines[i].strip()
269+
if not stripped or stripped.startswith('#'):
270+
continue
271+
if stripped == 'versions:':
272+
in_versions = True
273+
continue
274+
if in_versions:
275+
if stripped.startswith('- ') and not stripped.startswith('- name:'):
276+
last_ver_line = i
277+
ver_indent = len(lines[i]) - len(lines[i].lstrip())
278+
else:
279+
break # hit next key or next package
280+
elif stripped.startswith('- name:'):
281+
break # hit next package without finding versions
282+
283+
if last_ver_line is None:
284+
raise click.ClickException(f"Could not find versions block for '{name}'.")
285+
286+
for v in reversed(to_add):
287+
lines.insert(last_ver_line + 1, ' ' * ver_indent + f'- {v}\n')
288+
else:
289+
# Append new package block at the end of the file.
290+
if lines and not lines[-1].endswith('\n'):
291+
lines.append('\n')
292+
lines.append(f' - name: {name}\n')
293+
lines.append(f' versions:\n')
294+
for v in to_add:
295+
lines.append(f' - {v}\n')
221296

222-
# Option 1: Download and upload packages from packages.yml
297+
with open(packages_file, 'w') as f:
298+
f.writelines(lines)
299+
300+
return to_add, skipped
301+
302+
303+
@click.group()
304+
def cli():
305+
"""Manage NuGet package sync between Azure DevOps and GitHub Packages."""
306+
pass
307+
308+
309+
@cli.command()
310+
@click.argument("packages_file", type=click.Path(exists=True, dir_okay=False))
311+
@click.option("--package", default=None, help="Sync only this package name (must exist in the packages file).")
312+
def sync(packages_file, package):
313+
"""Sync packages from Azure DevOps to GitHub Packages."""
314+
syncer = NuGetSyncer(packages_file, package_filter=package)
223315
syncer.sync_packages()
224316

225-
# Option 2: Upload all existing packages in nupkgs directory
226-
# syncer.upload_all_packages_to_github()
317+
318+
@cli.command()
319+
@click.argument("packages_file", type=click.Path(dir_okay=False))
320+
@click.argument("name")
321+
@click.argument("versions", nargs=-1, required=True)
322+
@click.option("--skip-validate", is_flag=True, default=False,
323+
help="Skip Azure DevOps feed validation.")
324+
def register(packages_file, name, versions, skip_validate):
325+
"""Add NAME with one or more VERSIONS to PACKAGES_FILE.
326+
327+
Validates each version exists in the Azure DevOps feed before writing.
328+
Requires AZ_DEVOPS_PAT env var unless --skip-validate is set.
329+
"""
330+
if not skip_validate:
331+
az_pat = os.getenv("AZ_DEVOPS_PAT")
332+
if not az_pat:
333+
raise click.ClickException(
334+
"AZ_DEVOPS_PAT env var is required for validation. Use --skip-validate to bypass."
335+
)
336+
click.echo(f"Validating {name} against Azure DevOps feed...")
337+
_validate_versions(name, versions, az_pat)
338+
339+
added, skipped = _write_versions_to_file(packages_file, name, versions)
340+
341+
if added:
342+
click.echo(f"Registered {name}: {', '.join(added)}")
343+
if skipped:
344+
click.echo(f"Already in {packages_file}, skipped: {', '.join(skipped)}")
345+
if not added and not skipped:
346+
click.echo("Nothing to register.")
347+
348+
349+
if __name__ == "__main__":
350+
cli()

0 commit comments

Comments
 (0)