Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
57 changes: 56 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down Expand Up @@ -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/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dist/
*.toc
*.pyz
*.zip
*.nupkg
warn-*.txt
xref-*.html
localpycs/
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions packaging/choco/slcli.nuspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>slcli</id>
<version>$version$</version>
<title>SystemLink CLI</title>
<authors>NI</authors>
<owners>NI</owners>
<projectUrl>https://github.com/ni-kismet/systemlink-cli</projectUrl>
<license type="expression">MIT</license>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<summary>SystemLink Command Line Interface</summary>
<description>SystemLink CLI provides management and automation for SystemLink resources (workspaces, functions, notebooks, executions, etc.).</description>
<tags>systemlink ni cli automation</tags>
<packageSourceUrl>https://github.com/ni-kismet/systemlink-cli</packageSourceUrl>
<docsUrl>https://github.com/ni-kismet/systemlink-cli#readme</docsUrl>
<bugTrackerUrl>https://github.com/ni-kismet/systemlink-cli/issues</bugTrackerUrl>
<iconUrl>https://raw.githubusercontent.com/ni-kismet/systemlink-cli/main/docs/icon.png</iconUrl>
<releaseNotes>See CHANGELOG.md in the repository.</releaseNotes>
</metadata>
<files>
<!-- No embedded binaries: install script downloads release artifact -->
<file src="tools\**" target="tools" />
</files>
</package>
28 changes: 28 additions & 0 deletions packaging/choco/tools/chocolateyinstall.ps1
Original file line number Diff line number Diff line change
@@ -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."
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In PowerShell, the proper way to escape a dollar sign in a double-quoted string is with a backtick, not a backslash. The current usage of \$version\$ should be written as `$version`$ to correctly display the literal text "$version$" in the error message. While the current code may work, it doesn't follow PowerShell conventions and could be confusing.

Suggested change
throw "The package version could not be determined. Ensure that the build-time token `\$version\$` is replaced or the ChocolateyPackageVersion environment variable is set."
throw "The package version could not be determined. Ensure that the build-time token `$version$` is replaced or the ChocolateyPackageVersion environment variable is set."

Copilot uses AI. Check for mistakes.
}

$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.'
9 changes: 9 additions & 0 deletions packaging/choco/tools/chocolateyuninstall.ps1
Original file line number Diff line number Diff line change
@@ -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.'
150 changes: 150 additions & 0 deletions scripts/build_chocolatey.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function should verify that the $version$ token exists in the nuspec file before performing the replacement. If the token is missing, the package would be created with an invalid version, which could cause issues during publication or installation. Consider adding a check similar to the one in inject_checksum (but as an error rather than a warning).

Suggested change
content = NUSPEC.read_text(encoding="utf-8").replace("$version$", version)
content = NUSPEC.read_text(encoding="utf-8")
if "$version$" not in content:
print("✗ Error: $version$ token not found in nuspec template.", file=sys.stderr)
sys.exit(1)
content = content.replace("$version$", version)

Copilot uses AI. Check for mistakes.
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
Comment on lines +93 to +94
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checksum token is mandatory for Chocolatey package security (as mentioned in the PR description). If the $checksum$ token is not found in the install script, the function should exit with an error rather than just printing a warning and continuing. This would prevent accidentally packaging an install script without checksum verification, which would be a security issue.

Suggested change
print("Warning: checksum token not found in install script", file=sys.stderr)
return
print("✗ Error: checksum token ($checksum$) not found in install script. Aborting build.", file=sys.stderr)
sys.exit(1)

Copilot uses AI. Check for mistakes.
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()