diff --git a/.github/BRANCH_PROTECTION_RULESETS.md b/.github/BRANCH_PROTECTION_RULESETS.md
index 35e06c3..23cf839 100644
--- a/.github/BRANCH_PROTECTION_RULESETS.md
+++ b/.github/BRANCH_PROTECTION_RULESETS.md
@@ -7,7 +7,7 @@ This repository uses two GitHub branch protection rulesets.
1. **Default branch (main)**
- Target: branch name `main`.
- Require a pull request, stale review dismissal, and resolved review threads (self-approval allowed; no mandatory external approval).
- - Require status checks: **Analyze (python)**, **Unit tests (3.11)**, **Unit tests (3.12)**, **Compile + help smoke (macos-latest, 3.11)**, **Compile + help smoke (windows-latest, 3.11)**, **No build artifacts tracked**.
+ - Require status checks: **Analyze (python)**, **Unit tests (3.11)**, **Unit tests (3.12)**, **Compile + help smoke (macos-latest, 3.11)**, **Compile + help smoke (windows-latest, 3.11)**, **Windows unit tests**, **Windows MSI smoke**, **No build artifacts tracked**.
- Require linear history.
- Block force pushes and branch deletion.
- Bypass: none configured in rulesets.
@@ -34,6 +34,8 @@ This repository uses two GitHub branch protection rulesets.
- **Unit tests (3.12)**
- **Compile + help smoke (macos-latest, 3.11)**
- **Compile + help smoke (windows-latest, 3.11)**
+ - **Windows unit tests**
+ - **Windows MSI smoke**
- **No build artifacts tracked**
## Optional: apply via API
@@ -48,6 +50,8 @@ CONTEXTS='[
{"context":"Unit tests (3.12)"},
{"context":"Compile + help smoke (macos-latest, 3.11)"},
{"context":"Compile + help smoke (windows-latest, 3.11)"},
+ {"context":"Windows unit tests"},
+ {"context":"Windows MSI smoke"},
{"context":"No build artifacts tracked"}
]'
diff --git a/.github/ruleset-main.json b/.github/ruleset-main.json
index 8cb7b0a..d8cb79f 100644
--- a/.github/ruleset-main.json
+++ b/.github/ruleset-main.json
@@ -29,6 +29,8 @@
{ "context": "Unit tests (3.12)" },
{ "context": "Compile + help smoke (macos-latest, 3.11)" },
{ "context": "Compile + help smoke (windows-latest, 3.11)" },
+ { "context": "Windows unit tests" },
+ { "context": "Windows MSI smoke" },
{ "context": "No build artifacts tracked" }
]
}
diff --git a/.github/ruleset-release.json b/.github/ruleset-release.json
index 8297b42..1650247 100644
--- a/.github/ruleset-release.json
+++ b/.github/ruleset-release.json
@@ -29,6 +29,8 @@
{ "context": "Unit tests (3.12)" },
{ "context": "Compile + help smoke (macos-latest, 3.11)" },
{ "context": "Compile + help smoke (windows-latest, 3.11)" },
+ { "context": "Windows unit tests" },
+ { "context": "Windows MSI smoke" },
{ "context": "No build artifacts tracked" }
]
}
diff --git a/.github/workflows/bootstrap-winget.yml b/.github/workflows/bootstrap-winget.yml
index bac7b91..af1e465 100644
--- a/.github/workflows/bootstrap-winget.yml
+++ b/.github/workflows/bootstrap-winget.yml
@@ -2,6 +2,12 @@ name: Bootstrap WinGet package
"on":
workflow_dispatch:
+ inputs:
+ release_tag:
+ description: "Published release tag to submit (for example v0.1.7)"
+ required: true
+ default: "v0.1.7"
+ type: string
permissions:
contents: read
@@ -80,17 +86,59 @@ jobs:
$ErrorActionPreference = "Stop"
Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe -UseBasicParsing
+ - name: Prepare manifest from published release
+ id: prepare
+ shell: pwsh
+ env:
+ WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ run: |
+ $ErrorActionPreference = "Stop"
+ if ($env:RELEASE_TAG -notmatch '^v(\d+\.\d+\.\d+(?:\.\d+)?)$') {
+ throw "Release tag must use vX.Y.Z or vX.Y.Z.W."
+ }
+ $version = $Matches[1]
+ $headers = @{
+ Authorization = "Bearer $env:WINGET_TOKEN"
+ Accept = "application/vnd.github+json"
+ "X-GitHub-Api-Version" = "2022-11-28"
+ }
+ $release = Invoke-RestMethod `
+ -Headers $headers `
+ -Uri "https://api.github.com/repos/wildfoundry/dataplicity-cli/releases/tags/$env:RELEASE_TAG"
+ $assetName = "dataplicity-cli-$version-windows-x64.msi"
+ $asset = $release.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
+ if (-not $asset) {
+ throw "Release $env:RELEASE_TAG does not contain $assetName."
+ }
+ $downloadPath = Join-Path $env:RUNNER_TEMP $assetName
+ Invoke-WebRequest $asset.browser_download_url -OutFile $downloadPath -UseBasicParsing
+ $sha256 = (Get-FileHash $downloadPath -Algorithm SHA256).Hash
+ $releaseDate = ([DateTime]$release.published_at).ToString("yyyy-MM-dd")
+ $outputRoot = Join-Path $env:RUNNER_TEMP "winget-manifests"
+ $manifestOutput = python build/winget/prepare_manifest.py `
+ --source-dir "build/winget/manifests/w/Wildfoundry/DataplicityCLI/0.1.6" `
+ --output-root $outputRoot `
+ --version $version `
+ --installer-url $asset.browser_download_url `
+ --installer-sha256 $sha256 `
+ --release-date $releaseDate
+ $manifestPath = ($manifestOutput | Select-Object -Last 1).Trim()
+ "manifest_path=$manifestPath" >> $env:GITHUB_OUTPUT
+ "package_version=$version" >> $env:GITHUB_OUTPUT
+
- name: Submit initial manifest
shell: pwsh
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
+ MANIFEST_PATH: ${{ steps.prepare.outputs.manifest_path }}
+ PACKAGE_VERSION: ${{ steps.prepare.outputs.package_version }}
run: |
$ErrorActionPreference = "Stop"
- $manifestPath = "build/winget/manifests/w/Wildfoundry/DataplicityCLI/0.1.6"
- $prTitle = "New package: Wildfoundry.DataplicityCLI version 0.1.6"
+ $prTitle = "New package: Wildfoundry.DataplicityCLI version $env:PACKAGE_VERSION"
./wingetcreate.exe submit `
--token $env:WINGET_TOKEN `
--prtitle $prTitle `
--no-open `
- $manifestPath
+ $env:MANIFEST_PATH
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d1fdefd..727266e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,89 @@ jobs:
- name: Help smoke
run: python -m dataplicity_cli --help
+ windows-unit-tests:
+ name: Windows unit tests
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - name: Install
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[test]"
+ - name: Run unit tests
+ run: pytest -q --maxfail=1
+
+ windows-package-smoke:
+ name: Windows MSI smoke
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - name: Install build dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install . pyinstaller
+ choco install wixtoolset -y
+ - name: Build executable and MSI
+ shell: pwsh
+ run: |
+ $env:VERSION = python build/get_version.py
+ pyinstaller --noconfirm --clean --onefile --name dataplicity `
+ --workpath pyinstaller-build --distpath pyinstaller-dist `
+ dataplicity_cli/__main__.py
+ ./pyinstaller-dist/dataplicity.exe --version
+ ./pyinstaller-dist/dataplicity.exe --help
+ ./build/windows/build_msi.ps1 -Version $env:VERSION
+ "MSI_PATH=$((Resolve-Path "dist/dataplicity-cli-$env:VERSION-windows-x64.msi").Path)" >> $env:GITHUB_ENV
+ - name: Install, verify, and uninstall MSI
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+ $installDir = Join-Path $env:ProgramFiles "Dataplicity\Dataplicity CLI"
+ $installedExe = Join-Path $installDir "dataplicity.exe"
+ $installed = $false
+ try {
+ $install = Start-Process msiexec.exe `
+ -ArgumentList @("/i", "`"$env:MSI_PATH`"", "/qn", "/norestart") `
+ -Wait -PassThru
+ if ($install.ExitCode -ne 0) {
+ throw "MSI install failed with exit code $($install.ExitCode)."
+ }
+ $installed = $true
+ if (!(Test-Path $installedExe)) {
+ throw "Installed executable not found at $installedExe."
+ }
+ & $installedExe --version
+ if ($LASTEXITCODE -ne 0) {
+ throw "Installed executable --version failed."
+ }
+ & $installedExe --help
+ if ($LASTEXITCODE -ne 0) {
+ throw "Installed executable --help failed."
+ }
+ $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
+ if ($installDir -notin ($machinePath -split ";")) {
+ throw "Installer did not add $installDir to the machine PATH."
+ }
+ } finally {
+ if ($installed) {
+ $uninstall = Start-Process msiexec.exe `
+ -ArgumentList @("/x", "`"$env:MSI_PATH`"", "/qn", "/norestart") `
+ -Wait -PassThru
+ if ($uninstall.ExitCode -ne 0) {
+ throw "MSI uninstall failed with exit code $($uninstall.ExitCode)."
+ }
+ }
+ }
+ if (Test-Path $installedExe) {
+ throw "Installed executable remains after uninstall."
+ }
+
no-artifacts-tracked:
name: No build artifacts tracked
runs-on: ubuntu-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9eb3875..4f1c307 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -87,6 +87,16 @@ jobs:
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
+ - name: Verify Windows executable signature
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ $signature = Get-AuthenticodeSignature "pyinstaller-dist/dataplicity.exe"
+ if ($signature.Status -ne "Valid") {
+ throw "Executable signature is $($signature.Status): $($signature.StatusMessage)"
+ }
+ Write-Host "Executable signed by $($signature.SignerCertificate.Subject)"
+
- name: Build macOS tarball (for Homebrew)
if: runner.os == 'macOS'
shell: bash
@@ -136,6 +146,20 @@ jobs:
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
+ - name: Verify Windows MSI signature
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ $msi = Get-ChildItem "dist/*.msi" | Select-Object -First 1
+ if (-not $msi) {
+ throw "No MSI found to verify."
+ }
+ $signature = Get-AuthenticodeSignature $msi.FullName
+ if ($signature.Status -ne "Valid") {
+ throw "MSI signature is $($signature.Status): $($signature.StatusMessage)"
+ }
+ Write-Host "MSI signed by $($signature.SignerCertificate.Subject)"
+
- name: Stage Windows release artifacts
if: runner.os == 'Windows'
shell: pwsh
diff --git a/.github/workflows/update-winget.yml b/.github/workflows/update-winget.yml
index e5d7f39..b62c09f 100644
--- a/.github/workflows/update-winget.yml
+++ b/.github/workflows/update-winget.yml
@@ -34,7 +34,7 @@ jobs:
fi
- name: Publish to WinGet
- uses: vedantmgoyal9/winget-releaser@main
+ uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5
with:
identifier: Wildfoundry.DataplicityCLI
release-tag: ${{ github.event.release.tag_name || inputs.release_tag }}
diff --git a/README.md b/README.md
index b343d6b..025bf22 100644
--- a/README.md
+++ b/README.md
@@ -23,12 +23,26 @@ dataplicity --help
### Windows (no Python required)
-Download the latest `.msi` from [GitHub Releases](https://github.com/wildfoundry/dataplicity-cli/releases) and install it. It installs `dataplicity.exe` and adds it to `PATH`.
+Install the signed x64 MSI from WinGet in PowerShell or Windows Terminal:
-```
+```powershell
+winget install --id Wildfoundry.DataplicityCLI --exact
dataplicity --help
```
+WinGet handles future upgrades and uninstall:
+
+```powershell
+winget upgrade --id Wildfoundry.DataplicityCLI --exact
+winget uninstall --id Wildfoundry.DataplicityCLI --exact
+```
+
+The installer is machine-wide and may request administrator approval. If
+WinGet is unavailable, download the latest signed `.msi` from
+[GitHub Releases](https://github.com/wildfoundry/dataplicity-cli/releases).
+Both install paths add `dataplicity.exe` to `PATH`; open a new terminal after
+installation.
+
### Python (developer install)
If you do have Python available and prefer `pipx`:
@@ -211,3 +225,4 @@ dataplicity --install-completion zsh
- The `Update WinGet package` workflow publishes new `.msi` releases to WinGet using `Wildfoundry.DataplicityCLI`.
- Configure a repository secret named `WINGET_TOKEN` (classic PAT with `public_repo`) and ensure your account has a fork of `microsoft/winget-pkgs`.
- WinGet automation updates existing manifests; if this package is not yet in WinGet, submit the first manifest for the current release, then subsequent releases are automated.
+- Follow [`docs/windows-release.md`](docs/windows-release.md) before tagging a Windows release or submitting its first WinGet manifest.
diff --git a/build/windows/DataplicityCLI.wxs b/build/windows/DataplicityCLI.wxs
index a72ea50..6a9c6b3 100644
--- a/build/windows/DataplicityCLI.wxs
+++ b/build/windows/DataplicityCLI.wxs
@@ -14,7 +14,7 @@
InstallScope="perMachine" />
-
+
diff --git a/build/winget/prepare_manifest.py b/build/winget/prepare_manifest.py
new file mode 100644
index 0000000..3bef3b6
--- /dev/null
+++ b/build/winget/prepare_manifest.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import argparse
+import re
+import shutil
+from pathlib import Path
+
+
+def prepare_manifest(
+ *,
+ source_dir: Path,
+ output_root: Path,
+ version: str,
+ installer_url: str,
+ installer_sha256: str,
+ release_date: str,
+) -> Path:
+ if not re.fullmatch(r"\d+\.\d+\.\d+(?:\.\d+)?", version):
+ raise ValueError("Version must be numeric, for example 0.1.7.")
+ if not re.fullmatch(r"[0-9A-Fa-f]{64}", installer_sha256):
+ raise ValueError("Installer SHA-256 must contain 64 hexadecimal characters.")
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", release_date):
+ raise ValueError("Release date must use YYYY-MM-DD.")
+
+ source_version = None
+ for manifest in source_dir.glob("*.yaml"):
+ match = re.search(r"^PackageVersion:\s*(\S+)\s*$", manifest.read_text(encoding="utf-8"), re.MULTILINE)
+ if match:
+ source_version = match.group(1)
+ break
+ if source_version is None:
+ raise ValueError(f"No PackageVersion found under {source_dir}.")
+
+ output_dir = output_root / version
+ if output_dir.exists():
+ shutil.rmtree(output_dir)
+ shutil.copytree(source_dir, output_dir)
+
+ for manifest in output_dir.glob("*.yaml"):
+ text = manifest.read_text(encoding="utf-8")
+ text = text.replace(source_version, version)
+ text = re.sub(r"(?m)^ InstallerUrl: .+$", f" InstallerUrl: {installer_url}", text)
+ text = re.sub(
+ r"(?m)^ InstallerSha256: .+$",
+ f" InstallerSha256: {installer_sha256.upper()}",
+ text,
+ )
+ text = re.sub(r"(?m)^ReleaseDate: .+$", f"ReleaseDate: {release_date}", text)
+ manifest.write_text(text, encoding="utf-8")
+
+ return output_dir
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Prepare a versioned WinGet manifest from the bootstrap template.")
+ parser.add_argument("--source-dir", type=Path, required=True)
+ parser.add_argument("--output-root", type=Path, required=True)
+ parser.add_argument("--version", required=True)
+ parser.add_argument("--installer-url", required=True)
+ parser.add_argument("--installer-sha256", required=True)
+ parser.add_argument("--release-date", required=True)
+ args = parser.parse_args()
+
+ output_dir = prepare_manifest(
+ source_dir=args.source_dir,
+ output_root=args.output_root,
+ version=args.version,
+ installer_url=args.installer_url,
+ installer_sha256=args.installer_sha256,
+ release_date=args.release_date,
+ )
+ print(output_dir)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dataplicity_cli/cli.py b/dataplicity_cli/cli.py
index 3526a7c..6479e80 100644
--- a/dataplicity_cli/cli.py
+++ b/dataplicity_cli/cli.py
@@ -545,38 +545,19 @@ def _apply_tokens_or_none(state: AppContext, payload: Any) -> bool:
return True
-def _try_complete_sso_from_code(state: AppContext, code_payload: Dict[str, Any]) -> bool:
- code = code_payload.get("code")
- if not code:
- return False
- body: Dict[str, Any] = {"code": code}
- if code_payload.get("state"):
- body["state"] = code_payload["state"]
- response = state.api.post("/api/auth/sso/complete/", json_data=body)
- if not response.ok:
- return False
- return _apply_tokens_or_none(state, response.data)
-
-
def _attempt_sso_auto_complete(
state: AppContext,
listener: Optional[_SsoCallbackListener],
*,
timeout_seconds: int,
) -> bool:
+ if listener is None:
+ return False
deadline = time.monotonic() + max(timeout_seconds, 1)
while time.monotonic() < deadline:
- if listener:
- payload = listener.wait_for_payload(timeout_seconds=1.0)
- if payload:
- if _apply_tokens_or_none(state, payload):
- return True
- if _try_complete_sso_from_code(state, payload):
- return True
- response = state.api.get("/api/auth/sso/complete/")
- if response.ok and _apply_tokens_or_none(state, response.data):
+ payload = listener.wait_for_payload(timeout_seconds=1.0)
+ if payload and _apply_tokens_or_none(state, payload):
return True
- time.sleep(1.0)
return False
@@ -1805,7 +1786,7 @@ def auth_sso(
if payload is None:
_show_error(state.console, "Could not parse SSO response.")
raise typer.Exit(code=1)
- if not _apply_tokens_or_none(state, payload) and not _try_complete_sso_from_code(state, payload):
+ if not _apply_tokens_or_none(state, payload):
_show_error(state.console, "No access token found in payload.")
raise typer.Exit(code=1)
state.config.last_email = email
@@ -2998,7 +2979,7 @@ async def open_redirect_channel() -> int:
if identity_file:
ssh_cmd.extend(["-i", str(identity_file.expanduser())])
if not strict_host_key_checking:
- ssh_cmd.extend(["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"])
+ ssh_cmd.extend(["-o", "StrictHostKeyChecking=no", "-o", f"UserKnownHostsFile={os.devnull}"])
ssh_cmd.extend(ssh_arg or [])
ssh_cmd.append(target)
if remote_command:
diff --git a/dataplicity_cli/remote_access.py b/dataplicity_cli/remote_access.py
index 0f99dcf..33db39c 100644
--- a/dataplicity_cli/remote_access.py
+++ b/dataplicity_cli/remote_access.py
@@ -5,14 +5,24 @@
import secrets
import select
import sys
-import termios
import time
-import tty
from dataclasses import dataclass
from typing import Awaitable, Callable, Optional
from .m2m import M2MClient
+try:
+ import msvcrt
+except ImportError: # pragma: no cover - Windows only
+ msvcrt = None
+
+try:
+ import termios
+ import tty
+except ImportError: # pragma: no cover - Windows only
+ termios = None
+ tty = None
+
class RawTerminal:
def __init__(self) -> None:
@@ -20,13 +30,15 @@ def __init__(self) -> None:
self._old: Optional[list] = None
def __enter__(self) -> "RawTerminal":
+ if termios is None or tty is None:
+ return self
self._fd = sys.stdin.fileno()
self._old = termios.tcgetattr(self._fd)
tty.setraw(self._fd)
return self
def __exit__(self, exc_type, exc, tb) -> None:
- if self._fd is not None and self._old is not None:
+ if termios is not None and self._fd is not None and self._old is not None:
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)
@@ -45,6 +57,34 @@ class PortForwardEvent:
PortForwardEventCallback = Callable[[PortForwardEvent], None]
PortForwardChannelFactory = Callable[[], Awaitable[int]]
+_WINDOWS_KEY_SEQUENCES = {
+ "G": b"\x1b[H",
+ "H": b"\x1b[A",
+ "I": b"\x1b[5~",
+ "K": b"\x1b[D",
+ "M": b"\x1b[C",
+ "O": b"\x1b[F",
+ "P": b"\x1b[B",
+ "Q": b"\x1b[6~",
+ "S": b"\x1b[3~",
+}
+
+
+def _read_windows_console_input() -> bytes:
+ if msvcrt is None:
+ return b""
+ chunks = []
+ while msvcrt.kbhit():
+ character = msvcrt.getwch()
+ if character in {"\x00", "\xe0"}:
+ scan_code = msvcrt.getwch()
+ sequence = _WINDOWS_KEY_SEQUENCES.get(scan_code)
+ if sequence:
+ chunks.append(sequence)
+ continue
+ chunks.append(character.encode("utf-8", errors="replace"))
+ return b"".join(chunks)
+
def _detect_protocol(sample: bytes) -> Optional[str]:
if not sample:
@@ -70,6 +110,14 @@ async def run_terminal_session(m2m: M2MClient, port: int) -> None:
stop_event = asyncio.Event()
async def stdin_loop() -> None:
+ if msvcrt is not None:
+ while not stop_event.is_set():
+ data = _read_windows_console_input()
+ if data:
+ await m2m.send_route(port, data)
+ else:
+ await asyncio.sleep(0.02)
+ return
while not stop_event.is_set():
ready, _, _ = await asyncio.to_thread(select.select, [stdin_fd], [], [], 0.1)
if not ready:
diff --git a/docs/windows-release.md b/docs/windows-release.md
new file mode 100644
index 0000000..b6c5107
--- /dev/null
+++ b/docs/windows-release.md
@@ -0,0 +1,91 @@
+# Windows release runbook
+
+This runbook covers the x64, per-machine WiX MSI published as
+`Wildfoundry.DataplicityCLI`. Windows arm64 and portable/MSIX packages are not
+part of the current release scope.
+
+Do not submit v0.1.6 to WinGet. Its release uploaded the MSI without the
+external WiX cabinet, so the package does not contain the executable payload.
+The first WinGet version must be v0.1.7 or newer and must pass the MSI install
+smoke that verifies the embedded payload.
+
+## Distribution metadata
+
+- Product and command: `Dataplicity CLI` / `dataplicity`
+- Package identifier: `Wildfoundry.DataplicityCLI`
+- Installer: WiX MSI, x64, per-machine
+- License: `BSD-3-Clause`
+- Copyright holder: `Wildfoundry Ltd`
+- Current MSI and WinGet publisher: `Dataplicity`
+
+Before the first public WinGet submission, Legal or Product must confirm that
+`Dataplicity` is the intended public publisher name. Ops must also confirm that
+it is consistent with the Azure Artifact Signing certificate subject. Record
+the approval in the release issue.
+
+## Signing configuration
+
+The `Release` workflow uses GitHub OIDC and Azure Artifact Signing. Do not
+export a private key or store a PFX file in GitHub.
+
+The protected GitHub environment `release-signing` must provide:
+
+- Secret: `AZURE_CLIENT_ID`
+- Variables: `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`
+- Variables: `AZURE_ARTIFACT_SIGNING_ENDPOINT`
+- Variables: `AZURE_ARTIFACT_SIGNING_ACCOUNT`,
+ `AZURE_ARTIFACT_SIGNING_PROFILE`
+
+Ops owns the Azure signing account and must document internally:
+
+- primary and backup owner;
+- certificate/profile expiry date and renewal reminder;
+- who can approve the `release-signing` environment;
+- recovery steps for a failed or unavailable signing profile.
+
+Repository access only reveals secret and variable names, never their values.
+Validate the configuration by running a release build and checking both
+signatures rather than copying values into a ticket.
+
+## Release checklist
+
+1. Confirm the version in `pyproject.toml` and `dataplicity_cli/__init__.py`
+ matches the intended `vX.Y.Z` tag.
+2. Require green unit tests, Windows unit tests, and Windows MSI smoke.
+3. Complete the manual Windows functional smoke below.
+4. Create and push the release tag only after the preceding checks pass.
+5. Confirm the GitHub release contains the versioned x64 MSI and
+ `SHA256SUMS-windows-x64.txt`.
+6. Download the published MSI to a clean Windows 10 or 11 VM.
+7. Verify the MSI and installed executable:
+
+ ```powershell
+ Get-AuthenticodeSignature .\dataplicity-cli-X.Y.Z-windows-x64.msi
+ Get-FileHash .\dataplicity-cli-X.Y.Z-windows-x64.msi -Algorithm SHA256
+ ```
+
+ Both signatures must report `Valid`; the hash must match the release
+ checksum.
+8. Record the OS version, artifact hash, signing subject, timestamp, and smoke
+ results in the release issue.
+
+## Manual Windows smoke
+
+Use a non-production test organisation with one online Linux device and one
+offline device.
+
+- Install the MSI interactively and with `/qn`; verify `dataplicity` is on PATH
+ in a new PowerShell process.
+- Run `dataplicity --version`, `dataplicity --help`, and `dataplicity doctor`.
+- Test password login or MFA where enabled, browser SSO, token refresh,
+ `whoami`, and logout.
+- Run `dataplicity devices list` and inspect both online and offline devices.
+- Run a harmless command with `dataplicity devices run`.
+- Open and close `dataplicity devices terminal`.
+- Test `dataplicity devices ssh` with a valid key, a missing key, and a bad key.
+- Confirm an offline device fails promptly with an actionable error.
+- Upgrade from the previous MSI, then uninstall silently; verify the executable
+ and machine PATH entry are removed.
+
+Do not publish to WinGet if signing is invalid or a core smoke item fails.
+Document any accepted deferral with an owner, reason, and target release.
diff --git a/tests/test_remote_access_helpers.py b/tests/test_remote_access_helpers.py
index bade0d9..51f7e42 100644
--- a/tests/test_remote_access_helpers.py
+++ b/tests/test_remote_access_helpers.py
@@ -9,6 +9,7 @@
from typing import Dict, Optional
from unittest.mock import patch
+from dataplicity_cli import remote_access
from dataplicity_cli.remote_access import _detect_protocol, run_port_forward, run_remote_file, run_single_command
@@ -37,6 +38,17 @@ def __init__(self) -> None:
self.buffer = io.BytesIO()
+class _FakeMsvcrt:
+ def __init__(self, characters: list[str]) -> None:
+ self.characters = characters
+
+ def kbhit(self) -> bool:
+ return bool(self.characters)
+
+ def getwch(self) -> str:
+ return self.characters.pop(0)
+
+
def _unused_local_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
@@ -44,6 +56,23 @@ def _unused_local_port() -> int:
class RemoteAccessHelpersTest(unittest.IsolatedAsyncioTestCase):
+ async def test_windows_console_input_maps_text_and_navigation_keys(self) -> None:
+ fake_msvcrt = _FakeMsvcrt(["a", "\xe0", "H", "\xe0", "M", "\r"])
+
+ with patch.object(remote_access, "msvcrt", fake_msvcrt):
+ data = remote_access._read_windows_console_input()
+
+ self.assertEqual(data, b"a\x1b[A\x1b[C\r")
+
+ async def test_raw_terminal_is_noop_without_posix_terminal_modules(self) -> None:
+ with (
+ patch.object(remote_access, "termios", None),
+ patch.object(remote_access, "tty", None),
+ patch.object(remote_access.sys.stdin, "fileno", side_effect=AssertionError("fileno should not be called")),
+ ):
+ with remote_access.RawTerminal():
+ pass
+
async def test_run_single_command_rejects_empty_command(self) -> None:
fake = _FakeM2M()
with self.assertRaises(RuntimeError):
diff --git a/tests/test_sso_auth_flow.py b/tests/test_sso_auth_flow.py
index 24f8efb..8e0f04d 100644
--- a/tests/test_sso_auth_flow.py
+++ b/tests/test_sso_auth_flow.py
@@ -1,10 +1,13 @@
from __future__ import annotations
import unittest
+from types import SimpleNamespace
from urllib.request import urlopen
+from unittest.mock import Mock, patch
from dataplicity_cli.cli import (
_SsoCallbackListener,
+ _attempt_sso_auto_complete,
_coerce_timeout_seconds,
_extract_sso_payload_from_url,
_extract_sso_payload_from_query,
@@ -14,6 +17,16 @@
)
+class _FakeSsoListener:
+ def __init__(self, payload: dict | None) -> None:
+ self.payload = payload
+
+ def wait_for_payload(self, timeout_seconds: float) -> dict | None:
+ _ = timeout_seconds
+ payload, self.payload = self.payload, None
+ return payload
+
+
class SsoAuthFlowTest(unittest.TestCase):
def test_extract_sso_tokens_supports_nested_tokens(self) -> None:
access, refresh = _extract_sso_tokens({"tokens": {"access": "a1", "refresh": "r1"}})
@@ -53,6 +66,27 @@ def test_callback_listener_captures_query_payload(self) -> None:
finally:
listener.stop()
+ def test_auto_complete_uses_loopback_payload_without_backend_polling(self) -> None:
+ state = SimpleNamespace(api=Mock())
+ listener = _FakeSsoListener({"access": "abc", "refresh": "def"})
+
+ with patch("dataplicity_cli.cli._apply_tokens_or_none", return_value=True) as apply_tokens:
+ completed = _attempt_sso_auto_complete(state, listener, timeout_seconds=1)
+
+ self.assertTrue(completed)
+ apply_tokens.assert_called_once_with(state, {"access": "abc", "refresh": "def"})
+ state.api.get.assert_not_called()
+ state.api.post.assert_not_called()
+
+ def test_auto_complete_without_listener_returns_immediately(self) -> None:
+ state = SimpleNamespace(api=Mock())
+
+ completed = _attempt_sso_auto_complete(state, None, timeout_seconds=180)
+
+ self.assertFalse(completed)
+ state.api.get.assert_not_called()
+ state.api.post.assert_not_called()
+
def test_coerce_timeout_seconds_handles_invalid_values(self) -> None:
self.assertEqual(_coerce_timeout_seconds(30), 30)
self.assertEqual(_coerce_timeout_seconds("45"), 45)
diff --git a/tests/test_windows_packaging.py b/tests/test_windows_packaging.py
new file mode 100644
index 0000000..b845c83
--- /dev/null
+++ b/tests/test_windows_packaging.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+import unittest
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+
+WIX_SOURCE = Path(__file__).parents[1] / "build" / "windows" / "DataplicityCLI.wxs"
+WIX_NAMESPACE = {"wix": "http://schemas.microsoft.com/wix/2006/wi"}
+
+
+class WindowsPackagingTest(unittest.TestCase):
+ def test_msi_embeds_its_cabinet(self) -> None:
+ root = ET.parse(WIX_SOURCE).getroot()
+ media_template = root.find(".//wix:MediaTemplate", WIX_NAMESPACE)
+
+ self.assertIsNotNone(media_template)
+ self.assertEqual(media_template.get("EmbedCab"), "yes")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_winget_manifest.py b/tests/test_winget_manifest.py
new file mode 100644
index 0000000..278c7be
--- /dev/null
+++ b/tests/test_winget_manifest.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import importlib.util
+import tempfile
+import unittest
+from pathlib import Path
+
+
+SCRIPT_PATH = Path(__file__).parents[1] / "build" / "winget" / "prepare_manifest.py"
+SPEC = importlib.util.spec_from_file_location("prepare_manifest", SCRIPT_PATH)
+assert SPEC and SPEC.loader
+MODULE = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(MODULE)
+
+
+class WingetManifestTest(unittest.TestCase):
+ def test_prepares_versioned_manifest_with_release_artifact(self) -> None:
+ source_dir = (
+ Path(__file__).parents[1]
+ / "build"
+ / "winget"
+ / "manifests"
+ / "w"
+ / "Wildfoundry"
+ / "DataplicityCLI"
+ / "0.1.6"
+ )
+ installer_url = (
+ "https://github.com/wildfoundry/dataplicity-cli/releases/download/"
+ "v0.1.7/dataplicity-cli-0.1.7-windows-x64.msi"
+ )
+ installer_sha256 = "a" * 64
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ output_dir = MODULE.prepare_manifest(
+ source_dir=source_dir,
+ output_root=Path(temp_dir),
+ version="0.1.7",
+ installer_url=installer_url,
+ installer_sha256=installer_sha256,
+ release_date="2026-07-20",
+ )
+
+ manifests = list(output_dir.glob("*.yaml"))
+ self.assertEqual(len(manifests), 3)
+ combined = "\n".join(manifest.read_text(encoding="utf-8") for manifest in manifests)
+ self.assertNotIn("0.1.6", combined)
+ self.assertIn("PackageVersion: 0.1.7", combined)
+ self.assertIn(f" InstallerUrl: {installer_url}", combined)
+ self.assertIn(f" InstallerSha256: {installer_sha256.upper()}", combined)
+ self.assertIn("ReleaseDate: 2026-07-20", combined)
+
+ def test_rejects_invalid_sha256(self) -> None:
+ with self.assertRaises(ValueError):
+ MODULE.prepare_manifest(
+ source_dir=Path("unused"),
+ output_root=Path("unused"),
+ version="0.1.7",
+ installer_url="https://example.com/installer.msi",
+ installer_sha256="invalid",
+ release_date="2026-07-20",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()