diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e31286..427e06c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,8 +149,43 @@ jobs: path: | dist/scoop-slcli.json + choco-build: + runs-on: windows-latest + needs: build + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Poetry + run: pip install poetry + + - name: Install dependencies + run: poetry install + + - name: Download Windows artifact + uses: actions/download-artifact@v4 + with: + name: slcli-windows + path: dist/ + + - name: Build Chocolatey package + run: | + poetry run python scripts/build_chocolatey.py + + - name: Upload Chocolatey artifacts + uses: actions/upload-artifact@v4 + with: + name: choco-artifacts + path: | + dist/*.nupkg + release: - needs: [build, homebrew-build, scoop-build] + needs: [build, homebrew-build, scoop-build, choco-build] runs-on: ubuntu-latest permissions: contents: write @@ -167,6 +202,7 @@ jobs: slcli-linux/slcli-linux.tar.gz slcli-macos/slcli-macos.tar.gz slcli-windows/slcli.zip + choco-artifacts/*.nupkg generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -224,3 +260,22 @@ jobs: git add bucket/slcli.json git commit -m "slcli ${{ github.ref_name }}: update to latest release" git push + + publish-chocolatey: + needs: release + runs-on: windows-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Download Chocolatey artifacts + uses: actions/download-artifact@v4 + with: + name: choco-artifacts + path: choco/ + - name: Push package to Chocolatey + env: + CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} + run: | + if (-not $env:CHOCO_API_KEY) { Write-Error 'Missing CHOCOLATEY_API_KEY secret'; exit 1 } + $pkg = Get-ChildItem choco/*.nupkg | Select-Object -First 1 + choco apikey --source https://push.chocolatey.org/ --key $env:CHOCO_API_KEY + choco push $pkg.FullName --source https://push.chocolatey.org/ diff --git a/.gitignore b/.gitignore index 79e0c45..98a2b58 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ dist/ *.toc *.pyz *.zip +*.nupkg warn-*.txt xref-*.html localpycs/ diff --git a/README.md b/README.md index ec5bd34..de51d41 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,24 @@ scoop bucket add ni-kismet https://github.com/ni-kismet/scoop-ni scoop install slcli ``` +### Chocolatey (Windows) + +Install SystemLink CLI using Chocolatey from the public community repository: + +```powershell +# (Run from an elevated PowerShell / Command Prompt) +choco install slcli + +# Upgrade to the latest version when released +choco upgrade slcli +``` + +Notes: +- Requires an elevated shell (Run as Administrator) for system-wide install +- If you use a private Chocolatey server, push the published `.nupkg` there and install the same way +- To view the installed files: `choco list --local-only slcli -v` + + ### From Source For development or if Homebrew isn't available: diff --git a/packaging/choco/slcli.nuspec b/packaging/choco/slcli.nuspec new file mode 100644 index 0000000..b75e2c9 --- /dev/null +++ b/packaging/choco/slcli.nuspec @@ -0,0 +1,25 @@ + + + + slcli + $version$ + SystemLink CLI + NI + NI + https://github.com/ni-kismet/systemlink-cli + MIT + false + SystemLink Command Line Interface + SystemLink CLI provides management and automation for SystemLink resources (workspaces, functions, notebooks, executions, etc.). + systemlink ni cli automation + https://github.com/ni-kismet/systemlink-cli + https://github.com/ni-kismet/systemlink-cli#readme + https://github.com/ni-kismet/systemlink-cli/issues + https://raw.githubusercontent.com/ni-kismet/systemlink-cli/main/docs/icon.png + See CHANGELOG.md in the repository. + + + + + + diff --git a/packaging/choco/tools/chocolateyinstall.ps1 b/packaging/choco/tools/chocolateyinstall.ps1 new file mode 100644 index 0000000..2b6c610 --- /dev/null +++ b/packaging/choco/tools/chocolateyinstall.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = 'Stop' + +$packageName = 'slcli' +$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + +# Version is inferred from nuspec; construct download URL +# Use the Chocolatey provided variable if available +# '$version$' is a build-time token that should be replaced during packaging. +if ($env:ChocolateyPackageVersion) { $version = $env:ChocolateyPackageVersion } else { $version = '$version$' } +if ($version -eq '$version$') { + throw "The package version could not be determined. Ensure that the build-time token `\$version\$` is replaced or the ChocolateyPackageVersion environment variable is set." +} + +$zipName = "slcli.zip" +$downloadUrl = "https://github.com/ni-kismet/systemlink-cli/releases/download/v$version/$zipName" +$tempZip = Join-Path $toolsDir $zipName + +Write-Host "Downloading slcli $version from $downloadUrl" +Get-ChocolateyWebFile -PackageName $packageName -FileFullPath $tempZip -Url $downloadUrl -Checksum '$checksum$' -ChecksumType 'sha256' + +Write-Host 'Extracting archive' +Get-ChocolateyUnzip -FileFullPath $tempZip -Destination $toolsDir + +# Optional: remove zip after extraction +Remove-Item $tempZip -Force -ErrorAction SilentlyContinue + +# Chocolatey will shim slcli.exe in the extracted folder (assumed root of zip) +Write-Host 'slcli installation complete.' diff --git a/packaging/choco/tools/chocolateyuninstall.ps1 b/packaging/choco/tools/chocolateyuninstall.ps1 new file mode 100644 index 0000000..c6e3f6f --- /dev/null +++ b/packaging/choco/tools/chocolateyuninstall.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = 'Stop' + +$packageName = 'slcli' +$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + +Write-Host 'Removing slcli files' +# Rely on Chocolatey shim removal; optionally cleanup extracted directory +# Keep minimal to avoid removing user data unexpectedly +Write-Host 'Uninstall complete.' diff --git a/scripts/build_chocolatey.py b/scripts/build_chocolatey.py new file mode 100644 index 0000000..62ddf22 --- /dev/null +++ b/scripts/build_chocolatey.py @@ -0,0 +1,150 @@ +"""Build a Chocolatey package for slcli (nupkg) using remote artifact download model. + +Steps: +1. Ensure dist/slcli.zip (Windows binary zip) exists or has been uploaded to release. +2. Read version from pyproject.toml. +3. Copy nuspec & tools scripts to a temp build dir (or pack in place) and run `choco pack`. +4. Output .nupkg to dist/. + +This script expects to run on Windows with Chocolatey available in PATH. +""" + +from __future__ import annotations + +import hashlib +import shutil +import subprocess +import sys +from pathlib import Path + +import toml + +ROOT = Path(__file__).parent.parent.resolve() +DIST = ROOT / "dist" +PYPROJECT = ROOT / "pyproject.toml" +CHOCO_DIR = ROOT / "packaging" / "choco" +NUSPEC = CHOCO_DIR / "slcli.nuspec" + + +def get_version() -> str: + """Return project version from pyproject.toml.""" + data = toml.load(PYPROJECT) + return data["tool"]["poetry"]["version"] + + +def ensure_dist() -> None: + """Ensure dist directory exists.""" + DIST.mkdir(parents=True, exist_ok=True) + + +def prepare_nuspec(version: str, work_dir: Path) -> Path: + """Copy nuspec and replace $version$ token. + + Args: + version: semantic version string + work_dir: working temp directory + Returns: + Path to prepared nuspec + """ + target = work_dir / "slcli.nuspec" + content = NUSPEC.read_text(encoding="utf-8").replace("$version$", version) + target.write_text(content, encoding="utf-8") + return target + + +def copy_tools(work_dir: Path) -> Path: + """Copy Chocolatey tools scripts into working directory and return install script path. + + Args: + work_dir: Temporary working directory + Returns: + Path to chocolateyinstall.ps1 inside work_dir + """ + tools_src = CHOCO_DIR / "tools" + tools_dest = work_dir / "tools" + shutil.copytree(tools_src, tools_dest) + return tools_dest / "chocolateyinstall.ps1" + + +def compute_sha256(file_path: Path) -> str: + """Compute SHA256 hex digest for given file. + + Args: + file_path: Path to file + Returns: + Lowercase hex digest string + """ + h = hashlib.sha256() + with file_path.open("rb") as f: # pragma: no cover - simple IO + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def inject_checksum(install_script: Path, checksum: str) -> None: + """Replace $checksum$ token in install script with actual checksum. + + Args: + install_script: Path to chocolateyinstall.ps1 in temp work dir + checksum: SHA256 digest for slcli.zip + """ + content = install_script.read_text(encoding="utf-8") + if "$checksum$" not in content: + print("Warning: checksum token not found in install script", file=sys.stderr) + return + content = content.replace("$checksum$", checksum) + install_script.write_text(content, encoding="utf-8") + + +def run_choco_pack(work_dir: Path) -> Path: + """Run `choco pack` and return path to generated nupkg or exit on failure.""" + result = subprocess.run( + ["choco", "pack"], cwd=work_dir, text=True, stdout=sys.stdout, stderr=sys.stderr + ) + if result.returncode != 0: + print("choco pack failed", file=sys.stderr) + sys.exit(result.returncode) + pkgs = list(work_dir.glob("*.nupkg")) + if not pkgs: + print("No nupkg produced", file=sys.stderr) + sys.exit(1) + return pkgs[0] + + +def main() -> None: + """Entry point: prepare temp build area, pack nupkg, move to dist.""" + version = get_version() + ensure_dist() + work_dir = DIST / f"choco-build-{version}" + if work_dir.exists(): + shutil.rmtree(work_dir) + work_dir.mkdir(parents=True) + + prepare_nuspec(version, work_dir) + install_script = copy_tools(work_dir) + + # Compute checksum of pre-built Windows zip (expected in dist/) and inject + zip_path = DIST / "slcli.zip" + if not zip_path.exists(): + print("Expected dist/slcli.zip to exist before Chocolatey packaging", file=sys.stderr) + sys.exit(1) + checksum = compute_sha256(zip_path) + inject_checksum(install_script, checksum) + + print(f"Injected SHA256 checksum {checksum} into install script") + + print(f"Packing Chocolatey package for version {version}") + nupkg = run_choco_pack(work_dir) + final = DIST / nupkg.name + shutil.move(str(nupkg), final) + print(f"Created {final}") + + print( + "To publish (CI): choco push", + final.name, + "--source https://push.chocolatey.org/ --api-key ****", + ) + + +if __name__ == "__main__": # pragma: no cover + main()