diff --git a/.github/workflows/windows-bundles.yml b/.github/workflows/windows-bundles.yml new file mode 100644 index 0000000..feffbb8 --- /dev/null +++ b/.github/workflows/windows-bundles.yml @@ -0,0 +1,124 @@ +name: Windows bundles + +# Builds the Windows installers of the desktop app (an NSIS setup.exe and an +# .msi, x64) with scripts/build-windows.ps1 and keeps them as workflow artifacts +# for 3 days, so someone with a Windows machine can try a build of a branch. It +# runs ONLY when started by hand (Actions tab, or `gh workflow run +# windows-bundles.yml --ref `): never on push or pull_request, and it is +# not a required check. It publishes nothing: no release, no tag, no secret, no +# signing (the installers are NOT signed, so SmartScreen warns; there is no +# updater on Windows). Runs only on GitHub-hosted runners (this repo is public); +# never a self-hosted one (tests/test_workflows_guard.py). +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-bundles-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows-bundles: + runs-on: windows-latest + timeout-minutes: 60 + defaults: + run: + shell: pwsh + steps: + - name: Keep line endings as they are in the repository + run: git config --global core.autocrlf false + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The sidecar is frozen with Python 3.14, the version the macOS release uses. + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.14" + + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + workspaces: desktop/src-tauri + + - name: Build the NSIS installer and the .msi + run: scripts\build-windows.ps1 -Bundles nsis,msi + + - name: Bundle sizes and checksums + run: | + $bundle = "desktop/src-tauri/target/release/bundle" + $files = Get-ChildItem "$bundle/nsis/*.exe", "$bundle/msi/*.msi" + $lines = @("### Windows bundles", "", "| file | bytes | sha256 |", "|---|---|---|") + foreach ($f in $files) { + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $f.FullName).Hash.ToLower() + $lines += "| $($f.Name) | $($f.Length) | ``$hash`` |" + } + $lines | Tee-Object -Append -FilePath $env:GITHUB_STEP_SUMMARY + + # Does the frozen server start on its own? It must answer /api/health. + - name: Smoke test, the frozen server + run: | + $env:PORT = "17777" + $proc = Start-Process -FilePath "$PWD\dist\conductloop-server.exe" -PassThru -WindowStyle Hidden + try { + $ok = $false + for ($i = 0; $i -lt 60 -and -not $ok; $i++) { + Start-Sleep -Seconds 2 + try { $ok = (Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:17777/api/health" -TimeoutSec 3).StatusCode -eq 200 } catch { } + } + if (-not $ok) { throw "the frozen server did not answer /api/health on :17777" } + "the frozen server answered /api/health" | Tee-Object -Append -FilePath $env:GITHUB_STEP_SUMMARY + } finally { + # Only the process tree started above. + taskkill /T /F /PID $proc.Id | Out-Null + $global:LASTEXITCODE = 0 + } + + # Install the NSIS setup silently (per user) and start the installed app: + # the window needs WebView2, which the runner image has. Informational: a + # runner has no real desktop session, so a failure here is a warning, not + # a red build. + - name: Smoke test, install and start the app + continue-on-error: true + run: | + $setup = Get-ChildItem "desktop/src-tauri/target/release/bundle/nsis/*.exe" | Select-Object -First 1 + Start-Process -FilePath $setup.FullName -ArgumentList "/S" -Wait + $app = Get-ChildItem "$env:LOCALAPPDATA\ConductLoop" -Recurse -Filter *.exe | Where-Object { $_.Name -notmatch "unins" } + $app | ForEach-Object { "{0} {1} bytes" -f $_.FullName, $_.Length } + $main = $app | Where-Object { $_.Name -ne "conductloop-server.exe" } | Select-Object -First 1 + if (-not $main -or -not ($app | Where-Object { $_.Name -eq "conductloop-server.exe" })) { + throw "the installed app has no main executable or no conductloop-server.exe" + } + $proc = Start-Process -FilePath $main.FullName -PassThru + try { + $ok = $false + for ($i = 0; $i -lt 60 -and -not $ok; $i++) { + Start-Sleep -Seconds 2 + try { $ok = (Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:7777/api/health" -TimeoutSec 3).StatusCode -eq 200 } catch { } + } + if (-not $ok) { throw "the installed app did not start its server on :7777" } + "the installed app started its server" | Tee-Object -Append -FilePath $env:GITHUB_STEP_SUMMARY + } finally { + taskkill /T /F /PID $proc.Id | Out-Null + $global:LASTEXITCODE = 0 + } + + - name: Upload the NSIS installer + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: conductloop-windows-nsis + path: desktop/src-tauri/target/release/bundle/nsis/*.exe + if-no-files-found: error + retention-days: 3 + + - name: Upload the .msi + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: conductloop-windows-msi + path: desktop/src-tauri/target/release/bundle/msi/*.msi + if-no-files-found: error + retention-days: 3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee8e63..7388bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ User-facing changes to ConductLoop (the desktop app and its local server), newes ## Unreleased +- Windows: a CI workflow, "Windows bundles", builds the NSIS installer and the .msi on `windows-latest` and keeps them for 3 days as downloadable artifacts. It only runs when started by hand (it is not a check of pull requests), signs nothing and publishes nothing. CI only: the app does not change. - **Windows: a script to build an installer (experimental, unsigned).** `scripts/build-windows.ps1` freezes the server with PyInstaller and runs `tauri build --bundles nsis` (an `.msi` with `-Bundles nsis,msi`); the Windows settings live in `desktop/src-tauri/tauri.windows.conf.json`, so the macOS build does not change. The installer is not signed, so Windows SmartScreen warns when it runs ("More info", then "Run anyway"), and it needs WebView2 (included in Windows 11 and current Windows 10; the installer downloads it if missing). No updates on Windows: the app does not look for updates at start-up there. On Windows the bundled server now runs in UTF-8 mode. Not tested on a real Windows machine yet; Windows is still not a supported platform. `docs/PORTABILITY.md` has the status. ## 1.2.3 — 2026-10-29 — A home for your notes and a Brain that helps without a key diff --git a/docs/PORTABILITY.md b/docs/PORTABILITY.md index 441bfe3..efc0112 100644 --- a/docs/PORTABILITY.md +++ b/docs/PORTABILITY.md @@ -623,6 +623,15 @@ in-app browser, code sessions (no sandbox) and any release. software can also flag a PyInstaller one-file executable, which unpacks itself to a temporary folder on every start (slower start, false positives are common). Both go away only with a signed build, which is not planned yet. +- **CI bundle job.** `.github/workflows/windows-bundles.yml` runs + `scripts\build-windows.ps1 -Bundles nsis,msi` on `windows-latest` and keeps the + installers as artifacts for 3 days (`conductloop-windows-nsis`, + `conductloop-windows-msi`). It runs only by hand (`workflow_dispatch`: the + Actions tab, or `gh workflow run windows-bundles.yml --ref `), is not a + required check and publishes nothing. It also starts the frozen server and + checks that it answers `/api/health`, and tries to install and start the + installed app (informational: a CI runner has no real desktop). Nobody has + installed or run these installers on a real Windows machine yet. - **Known limits on Windows today:** the in-app browser is off, code sessions are off, API keys go to the encrypted vault (no Credential Manager yet) and the automated checks that exist run on `windows-latest` (see 8): compile and the diff --git a/tests/test_windows_bundles_workflow.py b/tests/test_windows_bundles_workflow.py new file mode 100644 index 0000000..3a25a0f --- /dev/null +++ b/tests/test_windows_bundles_workflow.py @@ -0,0 +1,88 @@ +"""The Windows bundles workflow (.github/workflows/windows-bundles.yml). + +It builds the Windows installers and uploads them as short-lived artifacts. It +runs only when started by hand, must not publish anything, take a secret, or +run anywhere but a GitHub-hosted runner. (tests/test_workflows_guard.py checks +pinning, permissions and runner labels for every workflow; these are the checks +specific to this one.) +""" +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +PATH = ROOT / ".github" / "workflows" / "windows-bundles.yml" +TEXT = PATH.read_text(encoding="utf-8") +DOC = yaml.safe_load(TEXT) +JOB = DOC["jobs"]["windows-bundles"] +TRIGGERS = DOC.get(True) or DOC.get("on") # PyYAML reads the key `on` as True + + +def test_it_runs_on_windows_latest_and_nowhere_else(): + assert list(DOC["jobs"]) == ["windows-bundles"] + assert JOB["runs-on"] == "windows-latest" + assert JOB["timeout-minutes"] <= 90 + + +def test_it_runs_only_when_started_by_hand(): + # Not on push, pull_request or a schedule: it is not a check of any change. + assert set(TRIGGERS) == {"workflow_dispatch"} + + +def test_it_is_not_a_required_check(): + """Required checks live in branch protection, not in the repo; the names a + job reports must not collide with the ones that are required there.""" + assert set(DOC["jobs"]).isdisjoint({"test", "gitleaks"}) + assert JOB.get("name", "") not in {"test", "gitleaks"} + + +def test_it_builds_with_the_script_on_windows(): + steps = "\n".join(str(s.get("run", "")) for s in JOB["steps"]) + assert "scripts\\build-windows.ps1" in steps + assert JOB["defaults"]["run"]["shell"] == "pwsh" + uses = " ".join(str(s.get("uses", "")) for s in JOB["steps"]) + for action in ("actions/checkout@", "actions/setup-python@", "dtolnay/rust-toolchain@", "Swatinem/rust-cache@"): + assert action in uses + + +def test_both_installers_are_uploaded_for_three_days(): + uploads = [s for s in JOB["steps"] if str(s.get("uses", "")).startswith("actions/upload-artifact@")] + assert len(uploads) == 2 + for step in uploads: + assert step["with"]["retention-days"] == 3 + assert step["with"]["if-no-files-found"] == "error" + paths = " ".join(s["with"]["path"] for s in uploads) + assert "bundle/nsis/*.exe" in paths and "bundle/msi/*.msi" in paths + + +def test_it_publishes_nothing_and_takes_no_secret(): + code = "\n".join(re.sub(r"(^|\s)#.*$", "", line) for line in TEXT.splitlines()) + lowered = code.lower() + for word in ("secrets.", "gh release", "gh api", "action-gh-release", "git push", "git tag", + "tauri_signing", "contents: write", "id-token", "self-hosted", "vars.", "signtool"): + assert word not in lowered, word + assert DOC["permissions"] == {"contents": "read"} + for step in JOB["steps"]: + assert "env" not in step or not any("secret" in str(v).lower() for v in step["env"].values()) + + +def test_the_installed_app_smoke_test_cannot_turn_the_build_red(): + # A runner has no real desktop session: that step is informational. + smoke = [s for s in JOB["steps"] if "install and start" in s.get("name", "")] + assert len(smoke) == 1 and smoke[0].get("continue-on-error") is True + # The frozen-server check is not optional: it needs no desktop session. + server = [s for s in JOB["steps"] if "frozen server" in s.get("name", "")] + assert len(server) == 1 and not server[0].get("continue-on-error") + + +def test_the_script_it_calls_exists(): + assert (ROOT / "scripts" / "build-windows.ps1").is_file() + + +def test_the_docs_mention_the_workflow(): + doc = (ROOT / "docs" / "PORTABILITY.md").read_text(encoding="utf-8") + assert "windows-bundles.yml" in doc + assert "workflow_dispatch" in doc[doc.index("windows-bundles.yml") - 600:doc.index("windows-bundles.yml") + 600] diff --git a/tests/test_workflows_guard.py b/tests/test_workflows_guard.py index c667335..e1b2ae4 100644 --- a/tests/test_workflows_guard.py +++ b/tests/test_workflows_guard.py @@ -22,6 +22,10 @@ # builds on ubuntu-22.04 so the AppImage works with an older glibc. HOSTED_LABELS = frozenset({"ubuntu-latest", "macos-latest", "windows-latest"}) PINNED_IMAGE_EXCEPTIONS = {("linux-bundles.yml", "linux-bundles"): "ubuntu-22.04"} +# The only workflows that may build installers and keep them as artifacts. Both +# publish nothing and keep what they build for 3 days at most: a new workflow +# that uploads an artifact has to be added here on purpose, in review. +BUNDLE_WORKFLOWS = frozenset({"linux-bundles.yml", "windows-bundles.yml"}) FORBIDDEN = ("self-hosted", "vars.runs_on", "runs_on") USES = re.compile(r"^\s*-?\s*uses:\s*(?P\S+)(?P.*)$") PINNED = re.compile(r"^[\w.\-]+/[\w.\-]+(/[\w.\-/]+)?@[0-9a-f]{40}$") @@ -126,3 +130,21 @@ def test_only_the_linux_bundles_job_runs_on_a_pinned_image(): if job.get("runs-on") not in HOSTED_LABELS: pinned[(path.name, name)] = job.get("runs-on") assert pinned == PINNED_IMAGE_EXCEPTIONS + + +def test_only_the_bundle_workflows_upload_artifacts_and_only_for_three_days(): + uploaders = set() + for path in WORKFLOWS: + doc = yaml.safe_load(path.read_text(encoding="utf-8")) + for name, job in (doc.get("jobs") or {}).items(): + for step in job.get("steps") or []: + if str(step.get("uses", "")).startswith("actions/upload-artifact@"): + uploaders.add(path.name) + assert step["with"]["retention-days"] <= 3, f"{path.name}:{name}: keep artifacts 3 days at most" + assert uploaders == BUNDLE_WORKFLOWS + + +def test_windows_latest_is_the_runner_of_the_windows_bundles_job_and_no_pinned_image_is_allowed_there(): + assert runner_problem("windows-bundles.yml", "windows-bundles", "windows-latest") is None + for label in ("windows-2022", "windows-2019", "windows-11-arm", "self-hosted", "ubuntu-22.04"): + assert runner_problem("windows-bundles.yml", "windows-bundles", label)