diff --git a/.github/workflows/windows-desktop.yml b/.github/workflows/windows-desktop.yml index 5fd5a00a..f1d42877 100644 --- a/.github/workflows/windows-desktop.yml +++ b/.github/workflows/windows-desktop.yml @@ -33,6 +33,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} artifact-name: ${{ steps.artifact.outputs.name }} + asset-base: ${{ steps.artifact.outputs.asset-base }} steps: - name: Checkout @@ -80,6 +81,7 @@ jobs: run: | $safeVersion = "${{ steps.version.outputs.version }}" -replace '[^A-Za-z0-9._-]', '-' "name=docsight-desktop-preview-win64-$safeVersion" >> $env:GITHUB_OUTPUT + "asset-base=DOCSight-Desktop-Preview-win64-$safeVersion" >> $env:GITHUB_OUTPUT - name: Upload desktop artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -87,8 +89,8 @@ jobs: name: ${{ steps.artifact.outputs.name }} if-no-files-found: error path: | - packaging/windows/dist/DOCSight-Desktop-Preview-win64-*.zip - packaging/windows/dist/DOCSight-Desktop-Preview-win64-*.zip.sha256 + packaging/windows/dist/${{ steps.artifact.outputs.asset-base }}.zip + packaging/windows/dist/${{ steps.artifact.outputs.asset-base }}.zip.sha256 attach-release-assets: if: github.event_name == 'release' @@ -108,4 +110,113 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_NAME: ${{ github.event.release.tag_name }} - run: gh release upload "$TAG_NAME" --repo "$GITHUB_REPOSITORY" release-assets/*.zip release-assets/*.sha256 --clobber + ASSET_BASE: ${{ needs.build.outputs.asset-base }} + run: | + gh release upload "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + "release-assets/$ASSET_BASE.zip" \ + "release-assets/$ASSET_BASE.zip.sha256" \ + --clobber + + - name: Verify published release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.event.release.tag_name }} + EXPECTED_ZIP: ${{ needs.build.outputs.asset-base }}.zip + EXPECTED_SHA256: ${{ needs.build.outputs.asset-base }}.zip.sha256 + run: | + max_attempts=3 + release_assets_read=0 + for ((attempt = 1; attempt <= max_attempts; attempt++)); do + if release_assets_output="$( + gh release view "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --json assets \ + --jq '.assets[].name' + )"; then + if [[ -n "$release_assets_output" ]]; then + release_assets_read=1 + break + fi + failure_message="Published release returned no assets." + else + failure_message="Unable to read published release assets." + fi + + if (( attempt < max_attempts )); then + echo "::warning::$failure_message Retrying ($attempt/$max_attempts)." + sleep "$((2 ** attempt))" + fi + done + if (( release_assets_read == 0 )); then + echo "::error::$failure_message Failed after $max_attempts attempts." + exit 1 + fi + + mapfile -t release_assets <<< "$release_assets_output" + expected_zip_count=0 + expected_sha256_count=0 + unexpected_preview_assets=0 + for asset in "${release_assets[@]}"; do + case "$asset" in + "$EXPECTED_ZIP") + ((expected_zip_count += 1)) + ;; + "$EXPECTED_SHA256") + ((expected_sha256_count += 1)) + ;; + *) + asset_lowercase="${asset,,}" + case "$asset_lowercase" in + docsight-desktop-preview-win64-*) + echo "::error::Published release contains unexpected Windows Preview asset: $asset" + unexpected_preview_assets=1 + ;; + esac + ;; + esac + done + + if (( expected_zip_count != 1 )); then + echo "::error::Published release must contain exactly one expected Windows Preview ZIP: $EXPECTED_ZIP" + exit 1 + fi + if (( expected_sha256_count != 1 )); then + echo "::error::Published release must contain exactly one expected Windows Preview checksum: $EXPECTED_SHA256" + exit 1 + fi + if (( unexpected_preview_assets != 0 )); then + exit 1 + fi + + verification_dir="$(mktemp -d)" + trap 'rm -rf -- "$verification_dir"' EXIT + if ! gh release download "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$verification_dir" \ + --pattern "$EXPECTED_ZIP" \ + --pattern "$EXPECTED_SHA256"; then + echo "::error::Unable to download the expected published Windows Preview assets." + exit 1 + fi + + zip_path="$verification_dir/$EXPECTED_ZIP" + checksum_path="$verification_dir/$EXPECTED_SHA256" + if [[ ! -f "$zip_path" || ! -f "$checksum_path" ]]; then + echo "::error::Downloaded Windows Preview assets are incomplete." + exit 1 + fi + + checksum_line="$(head -n 1 -- "$checksum_path")" + read -r expected_hash _ <<< "$checksum_line" + if [[ ! "$expected_hash" =~ ^[[:xdigit:]]{64}$ ]]; then + echo "::error::Published Windows Preview checksum is empty or invalid." + exit 1 + fi + + actual_hash_output="$(sha256sum -- "$zip_path")" + read -r actual_hash _ <<< "$actual_hash_output" + if [[ "${actual_hash,,}" != "${expected_hash,,}" ]]; then + echo "::error::Published Windows Preview ZIP does not match its published checksum." + exit 1 + fi diff --git a/CODE_SIGNING.md b/CODE_SIGNING.md index b7094b37..91cd78b6 100644 --- a/CODE_SIGNING.md +++ b/CODE_SIGNING.md @@ -53,16 +53,26 @@ with the Windows SDK SignTool: signtool verify /pa /v .\DOCSight\DOCSight.exe ``` -Until then, preview artifacts are unsigned. Download the ZIP and its -corresponding `.sha256` file from the same release, calculate the ZIP's SHA-256 -digest, and compare the values before use: +Until then, preview artifacts are unsigned. Each Windows Preview ZIP on the +official release has a corresponding `.sha256` asset for an optional advanced +integrity check. Download both files from the same release, then run this +optional PowerShell comparison from the folder containing them: ```powershell -Get-FileHash -Algorithm SHA256 .\DOCSight-Desktop-Preview-win64-.zip +$zip = ".\DOCSight-Desktop-Preview-win64-.zip" +$checksumFile = "$zip.sha256" +$checksumText = Get-Content -LiteralPath $checksumFile -Raw +$expectedHash = if ([string]::IsNullOrWhiteSpace($checksumText)) { "" } else { ($checksumText.Trim() -split '\s+', 2)[0] } +$actualHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash +$checksumMatches = [string]::Equals($actualHash, $expectedHash, [System.StringComparison]::OrdinalIgnoreCase) +$checksumMatches ``` -A matching checksum verifies download integrity against the published checksum. -It does not provide publisher authentication or replace a code signature. +The final command returns `True` only when the ZIP hash matches the first hash +value in the checksum file. SHA-256 is hexadecimal, so uppercase and lowercase +letters represent the same hash value and are compared case-insensitively. A +matching checksum verifies download integrity against the published checksum. +It does not verify publisher identity or replace a code signature. ## Privacy and security reporting diff --git a/INSTALL.md b/INSTALL.md index 163fad90..88fa6739 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,10 +8,10 @@ Covers Docker Run, Docker Compose, Portainer, Synology NAS, Unraid, updating, an | If you want to... | Start here | |---|---| -| Quickly try DOCSight on a Windows PC without Docker | [Desktop Preview for Windows](docs/windows-desktop-preview.md) | +| Quickly try DOCSight on a Windows PC without Docker | [Download the portable Desktop Preview](https://github.com/itsDNNS/docsight/releases/latest), then read the [usage notes](docs/windows-desktop-preview.md) | | Monitor your connection continuously on Windows | [Windows Docker Desktop quick start](docs/windows-quick-start.md) | -On Windows 10/11, the normal 24/7 monitoring path is Docker Desktop. Start with the [Windows quick start](docs/windows-quick-start.md) when you want the supported Docker path. The Desktop Preview is a portable tryout build and is not intended for always-on collection. +On Windows 10/11, the normal 24/7 monitoring path is Docker Desktop. Start with the [Windows quick start](docs/windows-quick-start.md) when you want the supported Docker path. The unsigned Desktop Preview is a portable tryout build published through GitHub Releases and is not intended for always-on collection. If setup or collection does not behave as expected, run the passive local doctor inside the same container before collecting manual environment details: diff --git a/README.md b/README.md index 78ea99b2..10616961 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@

Product page  •   Get Started  •   + Windows Preview  •   Supported Hardware  •   Wiki  •   Data contract  •   @@ -47,7 +48,7 @@ ## Get Started -Start with the fastest path for your setup. On Windows 10/11, use the [Windows quick start](docs/windows-quick-start.md) for Docker Desktop checks, a PowerShell-safe command, and common startup fixes. +Start with the fastest path for your setup. On Windows 10/11, download the unsigned portable [Windows Desktop Preview from the latest release](https://github.com/itsDNNS/docsight/releases/latest) for a quick first look without Docker. For continuous monitoring, use the [Windows quick start](docs/windows-quick-start.md). ### Option 1: Try the demo diff --git a/docs/index.html b/docs/index.html index 5ea6e51c..8c484d52 100644 --- a/docs/index.html +++ b/docs/index.html @@ -83,6 +83,7 @@ h1 { margin: 16px 0 18px; font-size: clamp(2.45rem, 5vw, 4.9rem); line-height: 1; letter-spacing: -0.07em; } .lead { margin: 0; color: #d8e2f4; font-size: clamp(1.08rem, 2vw, 1.32rem); max-width: 670px; } .hero-actions { display: flex; flex-wrap: wrap; gap: 14px; margin: 30px 0 24px; } + .preview-boundary { flex: 0 0 100%; max-width: 670px; margin: 0; color: var(--soft); font-size: 0.9rem; } .btn { display: inline-flex; align-items: center; @@ -208,6 +209,8 @@

Your ISP says everything is fine. DOCSight shows the timeline.

DOCSight records signal history, speed tests, latency, modem events and your own notes locally, so intermittent cable problems do not disappear into "looks fine from here".

Try the demo + Download Windows Preview +

The Windows download is an unsigned portable Preview for short local tests. For continuous monitoring, use Docker on an always-on machine.

See example evidence Install DOCSight
diff --git a/docs/windows-desktop-preview.md b/docs/windows-desktop-preview.md index 47af6613..4eff3b96 100644 --- a/docs/windows-desktop-preview.md +++ b/docs/windows-desktop-preview.md @@ -13,32 +13,47 @@ Use it for first contact, demos, and short local tests. For reliable 24/7 monito | Monitor your line continuously on a machine that stays awake, or after host restarts | Docker Desktop on an always-awake Windows PC, or another always-on Docker host | | Run DOCSight on a NAS, mini-PC, server, or homelab | Docker | -## Download and verify +## Download and start -1. Open the DOCSight [GitHub releases](https://github.com/itsDNNS/docsight/releases). -2. Download the Windows Desktop Preview ZIP when a release provides one. The asset name uses this shape: +1. Open the [latest DOCSight release](https://github.com/itsDNNS/docsight/releases/latest). +2. Under **Assets**, download the unsigned portable Windows Desktop Preview ZIP. Its versioned name uses this shape: ```text DOCSight-Desktop-Preview-win64-.zip ``` -3. Download the matching `.sha256` file. -4. In PowerShell, verify the checksum from the folder where you saved the files: +3. Extract the ZIP to a folder such as `Downloads\DOCSight` or `C:\Tools\DOCSight`. +4. Start `DOCSight.exe`. - ```powershell - Get-FileHash .\DOCSight-Desktop-Preview-win64-.zip -Algorithm SHA256 - Get-Content .\DOCSight-Desktop-Preview-win64-.zip.sha256 - ``` +You do not need a GitHub account for this download path. PowerShell and checksum verification are optional. + +## Optional: verify download integrity + +Each Windows Preview ZIP has a matching checksum asset named: + +```text +DOCSight-Desktop-Preview-win64-.zip.sha256 +``` + +For an optional integrity check, download that file from the same GitHub release. In PowerShell, run these commands from the folder where you saved both files: + +```powershell +$zip = ".\DOCSight-Desktop-Preview-win64-.zip" +$checksumFile = "$zip.sha256" +$checksumText = Get-Content -LiteralPath $checksumFile -Raw +$expectedHash = if ([string]::IsNullOrWhiteSpace($checksumText)) { "" } else { ($checksumText.Trim() -split '\s+', 2)[0] } +$actualHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash +$checksumMatches = [string]::Equals($actualHash, $expectedHash, [System.StringComparison]::OrdinalIgnoreCase) +$checksumMatches +``` - The hash values must match. -5. Extract the ZIP to a folder such as `Downloads\DOCSight` or `C:\Tools\DOCSight`. -6. Start `DOCSight.exe`. +The final command returns `True` only when the ZIP hash matches the first hash value in the checksum file. SHA-256 is hexadecimal, so uppercase and lowercase letters represent the same hash value and are compared case-insensitively. A matching SHA256 checksum verifies download integrity against the published checksum. It does not verify publisher identity and does not replace a code signature. ## First start and SmartScreen -The preview is a portable app. Early builds may be unsigned. Windows SmartScreen can therefore show an "unrecognized app" warning even when the checksum matches the release checksum. +The preview is a portable, unsigned app while signing provider onboarding is pending. Windows SmartScreen can therefore show an "unrecognized app" warning. -If you downloaded DOCSight from the official GitHub release and the SHA256 checksum matches, choose **More info** and then **Run anyway**. +If you downloaded DOCSight from the official GitHub release and choose to continue, select **More info** and then **Run anyway**. DOCSight starts a local web app and opens your default browser. The address is local to your PC, normally similar to: diff --git a/docs/windows-quick-start.md b/docs/windows-quick-start.md index 46fba659..b4e7eb67 100644 --- a/docs/windows-quick-start.md +++ b/docs/windows-quick-start.md @@ -4,10 +4,10 @@ DOCSight has two Windows paths: | If you want to... | Start here | |---|---| -| Try DOCSight without Docker | [Desktop Preview for Windows](windows-desktop-preview.md) | +| Try DOCSight without Docker | [Download the portable Desktop Preview](https://github.com/itsDNNS/docsight/releases/latest), then read the [usage notes](windows-desktop-preview.md) | | Monitor your connection continuously | Docker Desktop quick start below | -The Desktop Preview is a portable ZIP for demos and short local tryouts. For 24/7 monitoring, use Docker Desktop or another always-on Docker host. +The Desktop Preview is an unsigned portable ZIP for demos and short local tryouts. It is published through GitHub Releases. For 24/7 monitoring, use Docker Desktop or another always-on Docker host. DOCSight runs on Windows through Docker Desktop for continuous monitoring. Docker Desktop provides the Linux container runtime and DOCSight keeps its data in a Docker volume. diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 5e3444a8..aa8e160d 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -72,7 +72,7 @@ packaging/windows/dist/DOCSight-Desktop-Preview-win64-.zip.sha256 ``` Current preview artifacts are unsigned while provider onboarding is pending. -Downloaders should verify the preview ZIP against its published `.sha256` file. +The matching `.sha256` release asset is available for optional integrity checks. See the [code signing policy](../../CODE_SIGNING.md) for the onboarding status, intended release scope, and verification guidance. @@ -87,7 +87,8 @@ The `Windows Desktop Preview` workflow builds the portable package on `windows-latest`, smoke-tests the built `DOCSight.exe` against `http://127.0.0.1:/health`, and uploads the ZIP plus `.sha256` as workflow artifacts. Published releases also receive the ZIP and checksum as release -assets. +assets. Workflow artifacts are CI evidence only. Users download the unsigned +portable Preview from [GitHub Releases](https://github.com/itsDNNS/docsight/releases/latest). For local Windows smoke testing after a build: diff --git a/tests/test_desktop_preview_boundary.py b/tests/test_desktop_preview_boundary.py index df76d745..52b8bfb0 100644 --- a/tests/test_desktop_preview_boundary.py +++ b/tests/test_desktop_preview_boundary.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re from pathlib import Path from app import web @@ -9,9 +10,13 @@ I18N_DIR = ROOT / "app" / "i18n" INDEX_TEMPLATE = ROOT / "app" / "templates" / "index.html" SETTINGS_TEMPLATE = ROOT / "app" / "templates" / "settings.html" +README_DOC = ROOT / "README.md" DESKTOP_DOC = ROOT / "docs" / "windows-desktop-preview.md" INSTALL_DOC = ROOT / "INSTALL.md" WINDOWS_QUICK_START = ROOT / "docs" / "windows-quick-start.md" +CODE_SIGNING_DOC = ROOT / "CODE_SIGNING.md" +WINDOWS_PACKAGING_DOC = ROOT / "packaging" / "windows" / "README.md" +LATEST_RELEASE_URL = "https://github.com/itsDNNS/docsight/releases/latest" DESKTOP_KEYS = { "desktop_preview_badge", @@ -57,17 +62,33 @@ def test_desktop_preview_i18n_keys_exist_in_every_core_locale(): def test_desktop_preview_docs_cover_plain_language_capabilities_and_limits(): doc = DESKTOP_DOC.read_text(encoding="utf-8") - lower = doc.lower() - - assert "sha256" in lower - assert "smartscreen" in lower - assert "demo mode" in lower - assert "tcp" in lower - assert "icmp" in lower - assert "sleep" in lower - assert "hibernate" in lower or "hibernation" in lower - assert "%localappdata%\\docsight" in lower - assert "docker" in lower + capabilities = doc.split("## What works in the Desktop Preview", maxsplit=1)[ + 1 + ].split("## Known v0 limitations", maxsplit=1)[0] + limitations = doc.split("## Known v0 limitations", maxsplit=1)[1].split( + "## Where data lives", maxsplit=1 + )[0] + data_location = doc.split("## Where data lives", maxsplit=1)[1].split( + "## Remove the Desktop Preview", maxsplit=1 + )[0] + + for capability in ( + "- Demo Mode.", + "- Initial setup wizard.", + "- Evidence Journey and local diagnostic exports.", + "- Connection Monitor with TCP-based checks.", + ): + assert capability in capabilities + for limitation in ( + "**Not an always-on monitor.**", + "**Sleep and hibernate pause collection.**", + "**No native ICMP probing in v0.**", + "**No Windows service or autostart setup.**", + "**Local-only browser app.**", + ): + assert limitation in limitations + assert "%LOCALAPPDATA%\\DOCSight" in data_location + assert "For continuous monitoring, use Docker on an always-on machine instead." in limitations def test_windows_install_docs_link_tryout_to_preview_and_monitoring_to_docker(): @@ -81,3 +102,101 @@ def test_windows_install_docs_link_tryout_to_preview_and_monitoring_to_docker(): assert "24/7" in quick_start assert "Docker" in install assert "Docker" in quick_start + + +def test_windows_preview_is_visible_from_landing_and_uses_public_releases(): + readme = README_DOC.read_text(encoding="utf-8") + install = INSTALL_DOC.read_text(encoding="utf-8") + quick_start = WINDOWS_QUICK_START.read_text(encoding="utf-8") + preview = DESKTOP_DOC.read_text(encoding="utf-8") + + assert f'Windows Preview' in readme + assert ( + f"[Download the portable Desktop Preview]({LATEST_RELEASE_URL})" + in install + ) + assert ( + f"[Download the portable Desktop Preview]({LATEST_RELEASE_URL})" + in quick_start + ) + assert f"[latest DOCSight release]({LATEST_RELEASE_URL})" in preview + + assert "unsigned portable" in readme.lower() + assert "unsigned portable" in quick_start.lower() + + +def test_windows_preview_main_path_does_not_require_checksum_or_powershell(): + preview = DESKTOP_DOC.read_text(encoding="utf-8") + main_path = preview.split("## Download and start", maxsplit=1)[1].split( + "## Optional: verify download integrity", maxsplit=1 + )[0] + optional_check = preview.split( + "## Optional: verify download integrity", maxsplit=1 + )[1].split("## First start and SmartScreen", maxsplit=1)[0] + + assert "DOCSight-Desktop-Preview-win64-.zip" in main_path + assert ".zip.sha256" not in main_path + assert "```powershell" not in main_path + assert "You do not need a GitHub account for this download path." in main_path + assert "PowerShell and checksum verification are optional." in main_path + + assert "DOCSight-Desktop-Preview-win64-.zip.sha256" in optional_check + assert "```powershell" in optional_check + for verification_contract in ( + "$checksumText = Get-Content", + "[string]::IsNullOrWhiteSpace($checksumText)", + "$expectedHash =", + "-split '\\s+', 2", + "$actualHash = (Get-FileHash", + "[System.StringComparison]::OrdinalIgnoreCase", + "$checksumMatches", + "returns `True` only when", + "first hash value in the checksum file", + "compared case-insensitively", + "does not verify publisher identity", + ): + assert verification_contract in optional_check + + signing_text = CODE_SIGNING_DOC.read_text(encoding="utf-8") + signing = " ".join(signing_text.split()) + for shared_contract in ( + "$checksumText = Get-Content", + "[string]::IsNullOrWhiteSpace($checksumText)", + "$expectedHash =", + "$actualHash = (Get-FileHash", + "[System.StringComparison]::OrdinalIgnoreCase", + "$checksumMatches", + "returns `True` only when", + "first hash value in the checksum file", + "compared case-insensitively", + "does not verify publisher identity", + ): + assert shared_contract in signing + + preview_comparison = re.search( + r"```powershell\n(?P\$zip = .*?)\n```", + optional_check, + re.DOTALL, + ) + signing_comparison = re.search( + r"```powershell\n(?P\$zip = .*?)\n```", + signing_text, + re.DOTALL, + ) + assert preview_comparison + assert signing_comparison + assert preview_comparison.group("code") == signing_comparison.group("code") + + +def test_windows_release_docs_keep_actions_artifacts_as_ci_evidence_only(): + packaging = WINDOWS_PACKAGING_DOC.read_text(encoding="utf-8") + signing = CODE_SIGNING_DOC.read_text(encoding="utf-8") + + assert "Workflow artifacts are CI evidence only." in packaging + assert LATEST_RELEASE_URL in packaging + assert "optional integrity checks" in packaging + assert ( + "Each Windows Preview ZIP on the official release has a corresponding " + "`.sha256` asset for an optional advanced integrity check." + in " ".join(signing.split()) + ) diff --git a/tests/test_public_launch_surface.py b/tests/test_public_launch_surface.py index 24b8b9cf..fdfb02a7 100644 --- a/tests/test_public_launch_surface.py +++ b/tests/test_public_launch_surface.py @@ -14,6 +14,11 @@ README = ROOT / "README.md" SECURITY = ROOT / "SECURITY.md" DATA_CONTRACT = ROOT / "DATA_CONTRACT.md" +LATEST_RELEASE_URL = "https://github.com/itsDNNS/docsight/releases/latest" +PREVIEW_BOUNDARY_COPY = ( + "The Windows download is an unsigned portable Preview for short local tests. " + "For continuous monitoring, use Docker on an always-on machine." +) UNLINKED_PUBLIC_IMAGES = [ DOCS / "docsight.png", DOCS / "screenshots" / "setup.png", @@ -32,6 +37,10 @@ def __init__(self) -> None: self._in_title = False self.meta: dict[tuple[str, str], str] = {} self.links: list[str] = [] + self.link_details: list[tuple[str, str, str]] = [] + self._link_href = "" + self._link_class = "" + self._link_text: list[str] = [] self.images: list[str] = [] self.h1 = "" self._h1_depth = 0 @@ -47,6 +56,9 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self.meta[("property", data["property"])] = data["content"] if tag == "a" and data.get("href"): self.links.append(data["href"]) + self._link_href = data["href"] + self._link_class = data.get("class", "") + self._link_text = [] if tag == "img" and data.get("src"): self.images.append(data["src"]) if tag == "source" and data.get("srcset"): @@ -55,6 +67,12 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self._h1_depth = 1 def handle_endtag(self, tag: str) -> None: + if tag == "a" and self._link_href: + label = " ".join("".join(self._link_text).split()) + self.link_details.append((self._link_href, label, self._link_class)) + self._link_href = "" + self._link_class = "" + self._link_text = [] if tag == "title": self._in_title = False if tag == "h1": @@ -63,6 +81,8 @@ def handle_endtag(self, tag: str) -> None: def handle_data(self, data: str) -> None: if self._in_title: self.title += data + if self._link_href: + self._link_text.append(data) if self._h1_depth: self.h1 += data @@ -106,6 +126,32 @@ def test_landing_page_links_to_proof_pack_notes() -> None: assert "https://github.com/itsDNNS/docsight/blob/main/docs/proof-pack.md" in parser.links +def test_landing_page_has_visible_styled_windows_preview_release_action() -> None: + parser = parse_landing() + landing = INDEX.read_text(encoding="utf-8") + + matching_actions = [ + (label, classes) + for href, label, classes in parser.link_details + if href == LATEST_RELEASE_URL + ] + + assert matching_actions == [("Download Windows Preview", "btn")] + preview_entry = re.search( + rf'(?s)
\s*' + rf'Try the demo\s*' + rf'' + rf'Download Windows Preview\s*' + rf'

{re.escape(PREVIEW_BOUNDARY_COPY)}

\s*' + rf'See example evidence\s*' + rf'' + rf'Install DOCSight\s*' + rf'
\s*
    None: parser = parse_landing() diff --git a/tests/test_windows_desktop_ci.py b/tests/test_windows_desktop_ci.py index ccf092de..1cf7a203 100644 --- a/tests/test_windows_desktop_ci.py +++ b/tests/test_windows_desktop_ci.py @@ -2,9 +2,15 @@ from __future__ import annotations +import hashlib +import os import re +import subprocess +import textwrap from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "windows-desktop.yml" SMOKE_SCRIPT = ROOT / "packaging" / "windows" / "smoke_test.ps1" @@ -12,13 +18,146 @@ def named_step_block(workflow_text: str, step_name: str) -> str: match = re.search( - rf"(?m)^ - name: {re.escape(step_name)}\n(?P(?: .*\n)*)", + rf"(?ms)^ - name: {re.escape(step_name)}\n" + r"(?P.*?)(?=^ - name: |\Z)", workflow_text, ) assert match, f"step not found: {step_name}" return match.group("body") +def named_step_run_script(workflow_text: str, step_name: str) -> str: + block = named_step_block(workflow_text, step_name) + marker = " run: |\n" + assert marker in block, f"run script not found: {step_name}" + return textwrap.dedent(block.split(marker, maxsplit=1)[1]) + + +def run_release_verification( + tmp_path: Path, + assets: list[str], + *, + gh_exit_codes: list[int] | None = None, + download_exit_code: int = 0, + published_checksum: str | None = None, +) -> subprocess.CompletedProcess[str]: + workflow = WORKFLOW.read_text(encoding="utf-8") + script = named_step_run_script(workflow, "Verify published release assets") + zip_content = b"deterministic Windows Preview ZIP fixture\n" + expected_zip_hash = hashlib.sha256(zip_content).hexdigest() + gh_stub = tmp_path / "gh" + gh_stub.write_text( + "#!/usr/bin/env bash\n" + 'if [[ "$1" == "release" && "$2" == "view" ]]; then\n' + ' attempt="$(<"$GH_STUB_ATTEMPT_FILE")"\n' + ' attempt=$((attempt + 1))\n' + ' printf "%s" "$attempt" > "$GH_STUB_ATTEMPT_FILE"\n' + ' IFS=, read -r -a exit_codes <<< "$GH_STUB_EXIT_CODES"\n' + ' exit_code_index=$((attempt - 1))\n' + ' if ((exit_code_index >= ${#exit_codes[@]})); then\n' + ' exit_code_index=$((${#exit_codes[@]} - 1))\n' + " fi\n" + ' exit_code="${exit_codes[$exit_code_index]}"\n' + ' if ((exit_code == 0)); then\n' + ' printf "%s" "$GH_STUB_ASSETS"\n' + " fi\n" + ' exit "$exit_code"\n' + "fi\n" + 'if [[ "$1" == "release" && "$2" == "download" ]]; then\n' + ' if ((GH_STUB_DOWNLOAD_EXIT_CODE != 0)); then\n' + ' exit "$GH_STUB_DOWNLOAD_EXIT_CODE"\n' + " fi\n" + " shift 3\n" + ' download_dir=""\n' + " patterns=()\n" + " while (($#)); do\n" + ' case "$1" in\n' + " --repo)\n" + " shift 2\n" + " ;;\n" + " --dir)\n" + ' download_dir="$2"\n' + " shift 2\n" + " ;;\n" + " --pattern)\n" + ' patterns+=("$2")\n' + ' printf "%s\\n" "$2" >> "$GH_STUB_DOWNLOAD_PATTERNS_FILE"\n' + " shift 2\n" + " ;;\n" + " *)\n" + ' printf "unexpected gh argument: %s\\n" "$1" >&2\n' + " exit 64\n" + " ;;\n" + " esac\n" + " done\n" + ' mkdir -p "$download_dir"\n' + ' for pattern in "${patterns[@]}"; do\n' + ' case "$pattern" in\n' + ' "$EXPECTED_ZIP")\n' + ' printf "%s" "$GH_STUB_ZIP_CONTENT" > "$download_dir/$EXPECTED_ZIP"\n' + " ;;\n" + ' "$EXPECTED_SHA256")\n' + ' printf "%s\\n" "$GH_STUB_PUBLISHED_CHECKSUM" > "$download_dir/$EXPECTED_SHA256"\n' + " ;;\n" + " *)\n" + ' printf "unexpected download pattern: %s\\n" "$pattern" >&2\n' + " exit 65\n" + " ;;\n" + " esac\n" + " done\n" + " exit 0\n" + "fi\n" + 'printf "unexpected gh command: %s\\n" "$*" >&2\n' + "exit 64\n", + encoding="utf-8", + ) + gh_stub.chmod(0o755) + sleep_stub = tmp_path / "sleep" + sleep_stub.write_text( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$*" >> "$SLEEP_STUB_CALLS_FILE"\n', + encoding="utf-8", + ) + sleep_stub.chmod(0o755) + attempt_file = tmp_path / "gh-attempt" + attempt_file.write_text("0", encoding="utf-8") + sleep_calls_file = tmp_path / "sleep-calls" + download_patterns_file = tmp_path / "download-patterns" + verification_tmp_root = tmp_path / "verification-tmp" + verification_tmp_root.mkdir() + + expected_base = "DOCSight-Desktop-Preview-win64-v1.2.3" + env = { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "GH_STUB_ASSETS": "\n".join(assets), + "GH_STUB_EXIT_CODES": ",".join( + str(exit_code) for exit_code in (gh_exit_codes or [0]) + ), + "GH_STUB_ATTEMPT_FILE": str(attempt_file), + "GH_STUB_DOWNLOAD_EXIT_CODE": str(download_exit_code), + "GH_STUB_DOWNLOAD_PATTERNS_FILE": str(download_patterns_file), + "GH_STUB_ZIP_CONTENT": zip_content.decode("ascii"), + "GH_STUB_PUBLISHED_CHECKSUM": ( + expected_zip_hash if published_checksum is None else published_checksum + ), + "SLEEP_STUB_CALLS_FILE": str(sleep_calls_file), + "TMPDIR": str(verification_tmp_root), + "GH_TOKEN": "test-token", + "TAG_NAME": "v1.2.3", + "EXPECTED_ZIP": f"{expected_base}.zip", + "EXPECTED_SHA256": f"{expected_base}.zip.sha256", + "GITHUB_REPOSITORY": "itsDNNS/docsight", + } + return subprocess.run( + ["bash", "-e", "-c", script], + check=False, + capture_output=True, + env=env, + text=True, + ) + + def test_windows_desktop_workflow_triggers_and_permissions(): workflow = WORKFLOW.read_text(encoding="utf-8") @@ -64,6 +203,7 @@ def test_windows_desktop_workflow_builds_smokes_and_uploads_bundle(): build_block = named_step_block(workflow, "Build portable package") smoke_block = named_step_block(workflow, "Smoke-test built package") + artifact_block = named_step_block(workflow, "Resolve artifact name") upload_block = named_step_block(workflow, "Upload desktop artifact") assert "packaging/windows/build.ps1" in build_block @@ -71,8 +211,20 @@ def test_windows_desktop_workflow_builds_smokes_and_uploads_bundle(): assert "packaging/windows/smoke_test.ps1" in smoke_block assert "-BundleDir packaging/windows/dist/DOCSight" in smoke_block assert "-ExpectedVersion \"${{ steps.version.outputs.version }}\"" in smoke_block - assert "DOCSight-Desktop-Preview-win64-*.zip" in upload_block - assert "DOCSight-Desktop-Preview-win64-*.zip.sha256" in upload_block + assert ( + '"asset-base=DOCSight-Desktop-Preview-win64-$safeVersion"' + in artifact_block + ) + assert "asset-base: ${{ steps.artifact.outputs.asset-base }}" in workflow + assert ( + "packaging/windows/dist/${{ steps.artifact.outputs.asset-base }}.zip" + in upload_block + ) + assert ( + "packaging/windows/dist/${{ steps.artifact.outputs.asset-base }}.zip.sha256" + in upload_block + ) + assert "DOCSight-Desktop-Preview-win64-*.zip" not in upload_block def test_windows_desktop_workflow_attaches_release_assets(): @@ -81,9 +233,288 @@ def test_windows_desktop_workflow_attaches_release_assets(): release_block = named_step_block(workflow, "Attach assets to release") assert "GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}" in release_block assert "TAG_NAME: ${{ github.event.release.tag_name }}" in release_block - assert 'gh release upload "$TAG_NAME" --repo "$GITHUB_REPOSITORY"' in release_block - assert "release-assets/*.zip" in release_block - assert "release-assets/*.sha256" in release_block + assert "ASSET_BASE: ${{ needs.build.outputs.asset-base }}" in release_block + assert 'gh release upload "$TAG_NAME"' in release_block + assert '"release-assets/$ASSET_BASE.zip"' in release_block + assert '"release-assets/$ASSET_BASE.zip.sha256"' in release_block + assert "release-assets/*" not in release_block + + +def test_windows_desktop_workflow_verifies_exact_published_release_assets(): + workflow = WORKFLOW.read_text(encoding="utf-8") + + verify_block = named_step_block(workflow, "Verify published release assets") + assert "GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}" in verify_block + assert "TAG_NAME: ${{ github.event.release.tag_name }}" in verify_block + assert "EXPECTED_ZIP: ${{ needs.build.outputs.asset-base }}.zip" in verify_block + assert ( + "EXPECTED_SHA256: ${{ needs.build.outputs.asset-base }}.zip.sha256" + in verify_block + ) + assert 'gh release view "$TAG_NAME"' in verify_block + assert "--json assets" in verify_block + assert "--jq '.assets[].name'" in verify_block + assert "max_attempts=3" in verify_block + assert 'if release_assets_output="$(' in verify_block + assert 'sleep "$((2 ** attempt))"' in verify_block + assert "Failed after $max_attempts attempts." in verify_block + assert 'mapfile -t release_assets <<< "$release_assets_output"' in verify_block + assert verify_block.index('if release_assets_output="$(') < verify_block.index( + "mapfile -t release_assets" + ) + assert 'case "$asset" in' in verify_block + assert '"$EXPECTED_ZIP")' in verify_block + assert '"$EXPECTED_SHA256")' in verify_block + assert 'asset_lowercase="${asset,,}"' in verify_block + assert "docsight-desktop-preview-win64-*)" in verify_block + assert "expected_zip_count != 1" in verify_block + assert "expected_sha256_count != 1" in verify_block + assert 'verification_dir="$(mktemp -d)"' in verify_block + assert 'trap \'rm -rf -- "$verification_dir"\' EXIT' in verify_block + assert 'gh release download "$TAG_NAME"' in verify_block + assert '--pattern "$EXPECTED_ZIP"' in verify_block + assert '--pattern "$EXPECTED_SHA256"' in verify_block + assert 'sha256sum -- "$zip_path"' in verify_block + assert '"${actual_hash,,}" != "${expected_hash,,}"' in verify_block + assert verify_block.index("expected_sha256_count != 1") < verify_block.index( + 'gh release download "$TAG_NAME"' + ) + + +@pytest.mark.linux_only +def test_windows_desktop_release_verification_fails_closed_when_gh_fails(tmp_path): + result = run_release_verification(tmp_path, [], gh_exit_codes=[42]) + + assert result.returncode != 0 + assert "Unable to read published release assets." in result.stdout + assert "Failed after 3 attempts." in result.stdout + assert (tmp_path / "gh-attempt").read_text(encoding="utf-8") == "3" + assert (tmp_path / "sleep-calls").read_text(encoding="utf-8") == "2\n4\n" + + +@pytest.mark.linux_only +def test_windows_desktop_release_verification_retries_transient_gh_failure( + tmp_path, +): + result = run_release_verification( + tmp_path, + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ], + gh_exit_codes=[42, 0], + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Retrying (1/3)." in result.stdout + assert (tmp_path / "gh-attempt").read_text(encoding="utf-8") == "2" + assert (tmp_path / "sleep-calls").read_text(encoding="utf-8") == "2\n" + + +@pytest.mark.linux_only +def test_windows_desktop_release_verification_rejects_empty_asset_output(tmp_path): + result = run_release_verification(tmp_path, []) + + assert result.returncode != 0 + assert "Published release returned no assets." in result.stdout + + +@pytest.mark.linux_only +def test_windows_desktop_release_verification_allows_unrelated_assets(tmp_path): + result = run_release_verification( + tmp_path, + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + "docsight-linux-v1.2.3.tar.gz", + "another-product-v1.2.3.zip.asc", + "release-notes.json", + ], + ) + + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.linux_only +def test_windows_desktop_release_verification_downloads_only_exact_expected_assets( + tmp_path, +): + result = run_release_verification( + tmp_path, + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ], + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert ( + tmp_path / "download-patterns" + ).read_text(encoding="utf-8").splitlines() == [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ] + assert list((tmp_path / "verification-tmp").iterdir()) == [] + + +@pytest.mark.linux_only +def test_windows_desktop_release_verification_fails_closed_on_download_error( + tmp_path, +): + result = run_release_verification( + tmp_path, + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ], + download_exit_code=42, + ) + + assert result.returncode != 0 + assert ( + "Unable to download the expected published Windows Preview assets." + in result.stdout + ) + assert list((tmp_path / "verification-tmp").iterdir()) == [] + + +@pytest.mark.linux_only +def test_windows_desktop_release_verification_rejects_checksum_mismatch(tmp_path): + result = run_release_verification( + tmp_path, + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ], + published_checksum="0" * 64, + ) + + assert result.returncode != 0 + assert "does not match its published checksum." in result.stdout + assert list((tmp_path / "verification-tmp").iterdir()) == [] + + +@pytest.mark.linux_only +@pytest.mark.parametrize("published_checksum", ["", "not-a-sha256"]) +def test_windows_desktop_release_verification_rejects_empty_or_invalid_checksum( + tmp_path, + published_checksum, +): + result = run_release_verification( + tmp_path, + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ], + published_checksum=published_checksum, + ) + + assert result.returncode != 0 + assert "checksum is empty or invalid." in result.stdout + + +@pytest.mark.linux_only +def test_windows_desktop_release_verification_compares_checksum_case_insensitively( + tmp_path, +): + zip_content = b"deterministic Windows Preview ZIP fixture\n" + result = run_release_verification( + tmp_path, + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ], + published_checksum=hashlib.sha256(zip_content).hexdigest().upper(), + ) + + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.linux_only +@pytest.mark.parametrize( + "extra_asset", + [ + "DOCSight-Desktop-Preview-win64-v1.2.2.zip", + "DOCSight-Desktop-Preview-win64-v1.2.2.zip.sha256", + "docsight-desktop-preview-win64-v1.2.2.zip", + "DOCSight-Desktop-Preview-win64-v1.2.2.zip.asc", + ], +) +def test_windows_desktop_release_verification_rejects_stale_preview_assets( + tmp_path, + extra_asset, +): + result = run_release_verification( + tmp_path, + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + extra_asset, + ], + ) + + assert result.returncode != 0 + assert f"unexpected Windows Preview asset: {extra_asset}" in result.stdout + assert not (tmp_path / "download-patterns").exists() + + +@pytest.mark.linux_only +@pytest.mark.parametrize( + "assets", + [ + ["DOCSight-Desktop-Preview-win64-v1.2.3.zip"], + ["DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256"], + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ], + [ + "DOCSight-Desktop-Preview-win64-v1.2.3.zip", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + "DOCSight-Desktop-Preview-win64-v1.2.3.zip.sha256", + ], + ], +) +def test_windows_desktop_release_verification_requires_each_expected_asset_once( + tmp_path, + assets, +): + result = run_release_verification(tmp_path, assets) + + assert result.returncode != 0 + assert "must contain exactly one expected Windows Preview" in result.stdout + assert not (tmp_path / "download-patterns").exists() + + +def test_windows_desktop_workflow_does_not_delete_release_assets_by_pattern(): + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert "gh release delete-asset" not in workflow + assert not re.search( + r"(?im)^[ \t]*(?:rm|unlink|remove-item)\b[^\n]*[*?\[]", + workflow, + ) + + +@pytest.mark.linux_only +@pytest.mark.parametrize( + "step_name", + ["Attach assets to release", "Verify published release assets"], +) +def test_windows_desktop_bash_run_blocks_have_valid_syntax(step_name): + workflow = WORKFLOW.read_text(encoding="utf-8") + script = named_step_run_script(workflow, step_name) + + result = subprocess.run( + ["bash", "-n"], + check=False, + capture_output=True, + input=script, + text=True, + ) + + assert result.returncode == 0, result.stderr def test_smoke_script_launches_built_exe_and_checks_loopback_health(): diff --git a/tests/test_windows_packaging.py b/tests/test_windows_packaging.py index 52232558..24a68ae3 100644 --- a/tests/test_windows_packaging.py +++ b/tests/test_windows_packaging.py @@ -3,11 +3,23 @@ from __future__ import annotations import ast +import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] WINDOWS_PACKAGING = ROOT / "packaging" / "windows" CODE_SIGNING_POLICY = ROOT / "CODE_SIGNING.md" +WINDOWS_WORKFLOW = ROOT / ".github" / "workflows" / "windows-desktop.yml" + + +def safe_version_rule(text: str, assignment: str) -> tuple[str, str]: + match = re.search( + rf"{re.escape(assignment)}.*?-replace\s+" + r"'(?P[^']+)',\s*'(?P[^']*)'", + text, + ) + assert match, f"safe-version replacement not found for {assignment}" + return match.group("pattern"), match.group("replacement") def test_windows_packaging_files_exist(): @@ -74,6 +86,35 @@ def test_build_script_uses_hash_pinned_requirements_and_creates_zip_hash(): assert "Get-FileHash -Algorithm SHA256" in script +def test_workflow_and_build_script_apply_the_same_versioned_zip_convention(): + build_script = (WINDOWS_PACKAGING / "build.ps1").read_text(encoding="utf-8") + workflow = WINDOWS_WORKFLOW.read_text(encoding="utf-8") + build_rule = safe_version_rule(build_script, "return ($Value") + workflow_rule = safe_version_rule(workflow, '$safeVersion = "') + versions = { + "v1.2.3": "v1.2.3", + "2026-07-28_rc.1": "2026-07-28_rc.1", + "v1.2.3+build 7": "v1.2.3-build-7", + "release/1:beta?x": "release-1-beta-x", + "รค/1": "--1", + } + + for version, expected_safe_version in versions.items(): + expected_name = ( + f"DOCSight-Desktop-Preview-win64-{expected_safe_version}.zip" + ) + build_name = ( + "DOCSight-Desktop-Preview-win64-" + f"{re.sub(build_rule[0], build_rule[1], version)}.zip" + ) + workflow_name = ( + "DOCSight-Desktop-Preview-win64-" + f"{re.sub(workflow_rule[0], workflow_rule[1], version)}.zip" + ) + assert build_name == expected_name + assert workflow_name == expected_name + + def test_build_lock_contains_windows_pyinstaller_dependencies(): lock_text = (WINDOWS_PACKAGING / "requirements-build.txt").read_text(encoding="utf-8") diff --git a/tests/test_windows_pytest_ci.py b/tests/test_windows_pytest_ci.py index 9ed2b1ed..e72c6935 100644 --- a/tests/test_windows_pytest_ci.py +++ b/tests/test_windows_pytest_ci.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast import re from pathlib import Path @@ -9,6 +10,7 @@ TEST_WORKFLOW = ROOT / ".github" / "workflows" / "test.yml" WINDOWS_TEST_LOCK = ROOT / "packaging" / "windows" / "requirements-test-windows.txt" QA_CHECKLIST = ROOT / "packaging" / "windows" / "QA-CHECKLIST.md" +WINDOWS_DESKTOP_TESTS = ROOT / "tests" / "test_windows_desktop_ci.py" def job_block(workflow_text: str, job_name: str) -> str: @@ -71,6 +73,43 @@ def test_linux_only_marker_is_registered_and_reasoned(): assert "reason=\"Traceroute helper compile probe requires Linux C networking headers and gcc.\"" in source +def test_windows_desktop_bash_execution_is_marked_linux_only(): + tree = ast.parse(WINDOWS_DESKTOP_TESTS.read_text(encoding="utf-8")) + + for node in tree.body: + if not isinstance(node, ast.FunctionDef) or not node.name.startswith("test_"): + continue + + calls_bash = any( + isinstance(call, ast.Call) + and ( + ( + isinstance(call.func, ast.Name) + and call.func.id == "run_release_verification" + ) + or ( + isinstance(call.func, ast.Attribute) + and isinstance(call.func.value, ast.Name) + and call.func.value.id == "subprocess" + and call.func.attr == "run" + and call.args + and isinstance(call.args[0], ast.List) + and call.args[0].elts + and isinstance(call.args[0].elts[0], ast.Constant) + and call.args[0].elts[0].value == "bash" + ) + ) + for call in ast.walk(node) + ) + is_linux_only = any( + isinstance(decorator, ast.Attribute) + and decorator.attr == "linux_only" + for decorator in node.decorator_list + ) + + assert is_linux_only == calls_bash, node.name + + def test_manual_qa_checklist_covers_desktop_release_risks(): checklist = QA_CHECKLIST.read_text(encoding="utf-8") lower = checklist.lower()