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()