Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .github/workflows/windows-bundles.yml
Original file line number Diff line number Diff line change
@@ -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 <branch>`): 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/PORTABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`), 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
Expand Down
88 changes: 88 additions & 0 deletions tests/test_windows_bundles_workflow.py
Original file line number Diff line number Diff line change
@@ -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]
22 changes: 22 additions & 0 deletions tests/test_workflows_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<ref>\S+)(?P<rest>.*)$")
PINNED = re.compile(r"^[\w.\-]+/[\w.\-]+(/[\w.\-/]+)?@[0-9a-f]{40}$")
Expand Down Expand Up @@ -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)
Loading