Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
21274ce
feat(desktop): ship signed Windows NSIS updates on the main channel
JasonBuildAI Oct 19, 2026
b57a37f
docs(desktop): describe the Windows signed-update journey
JasonBuildAI Oct 19, 2026
96332b1
fix(desktop): keep the Windows boot surface inside the navigation fence
JasonBuildAI Oct 19, 2026
0584075
fix(desktop): fail closed on invalid updater signatures and pin the w…
JasonBuildAI Oct 19, 2026
e71d9e0
fix(desktop): keep Windows install failures out of the macOS journal …
JasonBuildAI Oct 19, 2026
52856e3
fix(desktop): keep updater copy off controls Windows does not offer
JasonBuildAI Oct 19, 2026
60fb4fb
fix(desktop): align the published signature and journal gates with th…
JasonBuildAI Oct 19, 2026
1a3971f
test(desktop): keep the Windows signature rejection the actual failur…
JasonBuildAI Oct 19, 2026
09d1162
ci: retrigger desktop checks after a transient bundling failure
JasonBuildAI Oct 19, 2026
a585584
fix(desktop): reclaim the App's services across the Windows update ha…
JasonBuildAI Oct 19, 2026
c668057
test(desktop): drive the pinned updater's verified-but-unlaunchable h…
JasonBuildAI Oct 19, 2026
c477078
fix(desktop): carry the reviewed Windows journey through main's autom…
JasonBuildAI Oct 19, 2026
8fd7912
ci(desktop): run the Windows update lifecycle regressions on a native…
JasonBuildAI Oct 19, 2026
3e75c43
test(desktop): align the update panel smoke with the merged automatic…
JasonBuildAI Oct 19, 2026
257d3ce
ci(desktop): prepare the bundled runtime before the Windows update tests
JasonBuildAI Oct 19, 2026
eb29f77
fix(desktop): scope the update promise to the channel that publishes it
JasonBuildAI Oct 19, 2026
1700621
test(desktop): align update smoke with runtime node telemetry
JasonBuildAI Oct 20, 2026
0072e47
ci(desktop): keep the stable feed independent of the Windows build
JasonBuildAI Oct 21, 2026
f6cf571
fix(desktop): skip absent Windows artifacts on stable upload
JasonBuildAI Oct 21, 2026
7392feb
test(desktop): restore the shared update-failure smoke coverage
JasonBuildAI Oct 21, 2026
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
139 changes: 124 additions & 15 deletions .github/workflows/desktop-updater.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ jobs:
- run: python3 -m unittest discover -s tests/desktop -p 'test_*.py'
- run: cargo test --locked && cargo clippy --all-targets --locked -- -D warnings
working-directory: apps/desktop/loopx-control-plane/src-tauri
windows-desktop-tests:
# The lifecycle and update regressions behind the signed Windows updater
# are Windows-native (service job objects, `ShellExecuteW`, real child
# processes) and cannot execute on the macOS `validate` leg. Compile the
# whole test target natively and run those regressions here.
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
# The signed runtime bundle the crate's build script embeds has to exist
# before the test target can compile, exactly as on the macOS leg.
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: actions/setup-node@v6
with:
node-version: "24"
- run: python scripts/desktop_runtime_bundle.py
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/desktop/loopx-control-plane/src-tauri -> target
key: desktop-update-windows-tests
- run: cargo test --locked --lib -- service_lifecycle
working-directory: apps/desktop/loopx-control-plane/src-tauri
identity:
if: github.repository == 'loopx-project/loopx' && ((github.event_name == 'release' && !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'v')) || (github.event_name != 'release' && github.ref == 'refs/heads/main'))
runs-on: ubuntu-latest
Expand Down Expand Up @@ -67,16 +92,10 @@ jobs:
echo "version=0.0.0-main.${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
echo "tag=desktop-main-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
fi
build:
build-macos:
needs: identity
if: needs.identity.outputs.channel == 'stable' || needs.identity.outputs.channel == 'main'
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
bundle: app
runs-on: ${{ matrix.runner }}
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
Expand All @@ -94,33 +113,118 @@ jobs:
workspaces: apps/desktop/loopx-control-plane/src-tauri -> target
- run: npm ci
working-directory: apps/desktop/loopx-control-plane
- name: Build signed App and exact bundled runtime
- name: Build signed macOS App and exact bundled runtime
if: runner.os == 'macOS'
working-directory: apps/desktop/loopx-control-plane
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
VERSION: ${{ needs.identity.outputs.version }}
BUNDLE: ${{ matrix.bundle }}
BUNDLE: app
run: |
set -euo pipefail
test -n "$TAURI_SIGNING_PRIVATE_KEY"
npm run build -- --bundles "$BUNDLE" --config "{\"version\":\"$VERSION\",\"bundle\":{\"createUpdaterArtifacts\":true,\"macOS\":{\"signingIdentity\":\"-\"}}}"
- name: Collect native updater artifacts
shell: bash
env:
VERSION: ${{ needs.identity.outputs.version }}
run: |
mkdir -p dist/updater
find apps/desktop/loopx-control-plane/src-tauri/target/release/bundle -type f \( -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' -o -name '*-setup.exe' -o -name '*-setup.exe.sig' \) -exec cp {} dist/updater/ \;
for artifact in dist/updater/*-setup.exe*; do
if [ -f "$artifact" ]; then mv "$artifact" "${artifact/-setup.exe/-updater-setup.exe}"; fi
if [ -f "$artifact" ]; then
case "$artifact" in
*-updater-setup.exe*) continue ;;
esac
mv "$artifact" "${artifact/-setup.exe/-updater-setup.exe}"
fi
done
if [ "$RUNNER_OS" = "Windows" ]; then
# The feed advertises this run's version. An installer whose name
# does not embed it would be published under a version it does not
# carry, and clients would never update again.
ls dist/updater/*"$VERSION"*-updater-setup.exe >/dev/null
fi
- uses: actions/upload-artifact@v7
with:
name: signed-updater-${{ matrix.runner }}
name: signed-updater-macos-15
path: dist/updater/*
if-no-files-found: error
build-windows:
needs: identity
# Windows NSIS ships on the main channel only. Keeping this leg out of the
# stable dependency graph means a Windows failure cannot withhold the
# Darwin-only stable feed; main still requires it.
if: needs.identity.outputs.channel == 'main'
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.identity.outputs.ref }}
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/desktop/loopx-control-plane/src-tauri -> target
- run: npm ci
working-directory: apps/desktop/loopx-control-plane
- name: Build signed Windows NSIS updater installer
if: runner.os == 'Windows'
working-directory: apps/desktop/loopx-control-plane
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
VERSION: ${{ needs.identity.outputs.version }}
BUNDLE: nsis
run: |
set -euo pipefail
test -n "$TAURI_SIGNING_PRIVATE_KEY"
npm run build -- --bundles "$BUNDLE" --config "{\"version\":\"$VERSION\",\"bundle\":{\"createUpdaterArtifacts\":true}}"
- name: Collect native updater artifacts
shell: bash
env:
VERSION: ${{ needs.identity.outputs.version }}
run: |
mkdir -p dist/updater
find apps/desktop/loopx-control-plane/src-tauri/target/release/bundle -type f \( -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' -o -name '*-setup.exe' -o -name '*-setup.exe.sig' \) -exec cp {} dist/updater/ \;
for artifact in dist/updater/*-setup.exe*; do
if [ -f "$artifact" ]; then
case "$artifact" in
*-updater-setup.exe*) continue ;;
esac
mv "$artifact" "${artifact/-setup.exe/-updater-setup.exe}"
fi
done
if [ "$RUNNER_OS" = "Windows" ]; then
# The feed advertises this run's version. An installer whose name
# does not embed it would be published under a version it does not
# carry, and clients would never update again.
ls dist/updater/*"$VERSION"*-updater-setup.exe >/dev/null
fi
- uses: actions/upload-artifact@v7
with:
name: signed-updater-windows-latest
path: dist/updater/*
if-no-files-found: error
publish:
needs: [identity, build, validate]
needs: [identity, build-macos, build-windows, validate]
# A skipped Windows build is valid only on stable, where the feed is
# Darwin-only. A main release must have its Windows artifact.
if: >-
always()
&& needs.identity.result == 'success'
&& needs.build-macos.result == 'success'
&& needs.validate.result == 'success'
&& (needs.identity.outputs.channel != 'main' || needs.build-windows.result == 'success')
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -137,7 +241,8 @@ jobs:
env:
VERSION: ${{ needs.identity.outputs.version }}
RELEASE_TAG: ${{ needs.identity.outputs.tag }}
run: python scripts/desktop_update_feed.py --directory dist/updater --version "$VERSION" --tag "$RELEASE_TAG"
CHANNEL: ${{ needs.identity.outputs.channel }}
run: python scripts/desktop_update_feed.py --directory dist/updater --version "$VERSION" --tag "$RELEASE_TAG" --channel "$CHANNEL"
- name: Upload immutable artifacts, publish channel pointer last
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -147,10 +252,14 @@ jobs:
CHANNEL: ${{ needs.identity.outputs.channel }}
run: |
set -euo pipefail
# Stable builds intentionally have no Windows artifact. Without
# nullglob, Bash passes the unmatched pattern to `gh`, which exits
# before the feed and channel pointer are published.
shopt -s nullglob
if [ "$EVENT_NAME" != release ]; then
gh release create "$RELEASE_TAG" --prerelease --target "$SOURCE_REF" --title "Desktop main $SOURCE_REF" --notes "Signed App and matching runtime preview."
fi
for artifact in dist/updater/*.gz dist/updater/*.sig; do
for artifact in dist/updater/*.gz dist/updater/*.sig dist/updater/*.exe; do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Skip absent Windows artifacts on the stable publishing path. Stable now intentionally skips build-windows, leaving no .exe. With no nullglob/file guard, Bash passes the literal dist/updater/*.exe to gh, which exits with no matches; set -e stops before feed/pointer publication. The exact base upload script succeeds for the same Darwin-only fixture; head stable fails, while head main and a nullglob-only control succeed. Enumerate only existing artifacts and execute the full stable/main upload loop in the regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复于 dcbc04589d5f487bc24537a0d7e49e71a027168a(rebase 到 c97f921e2f18fbd7e155495b7eb5029031c97fe3)。

  • 上传步骤在枚举 .gz / .sig / .exe 前启用 shopt -s nullglob。stable 的 Darwin-only 产物现在会跳过不存在的 .exe,继续上传 immutable feed 和 desktop-stable 指针;main 仍要求 Windows 构建,缺少 .exe 时前置 feed builder 在上传前 fail closed。
  • 回归测试执行了生产上传脚本块本身,并用假的 gh 调用日志覆盖 stable Darwin-only 与 main 完整产物:stable 不传 .exe 或字面量 glob,main 会传 Windows installer,feed 上传先于指针上传。
  • 当前 head 验证:desktop 28 passed;Windows service_lifecycle 6 passed;PR CI 的 validate 与 windows-desktop-tests 均通过。
  • 同 base 的 canary premerge 为 18/19:唯一失败是 hot-path-interface-budget-smoke 在 Windows 上的既有 extension doctor entrypoint_missing(相关 readiness.py 不在本 diff),public boundary 通过。cargo fmt --check 仍报告 main 上同样存在的 services.rs 格式差异,未声称已修复。

请按 exact head dcbc04589 复审;本轮不合并。

gh release upload "$RELEASE_TAG" "$artifact"
done
gh release upload "$RELEASE_TAG" dist/updater/desktop-updater.json --clobber
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ native desktop previews from the [current release](https://github.com/loopx-proj
they reuse the same loopback services and Goal state. Apple Silicon macOS supports
signed App updates that pair the shell with its bundled runtime, plus repair and
recovery. Python 3.11+ is required; the App is ad-hoc signed, not notarized.
Windows preview installers currently use manual updates and a separately installed CLI.
Windows NSIS preview installers support the same signed update flow on the main
channel, with a separately installed CLI; the stable channel and MSI installs stay
manual, App rollback and repair are unavailable, and the installer carries no
Authenticode certificate (Windows may show a SmartScreen prompt).
[Desktop installation, updates, and source development](apps/desktop/loopx-control-plane/README.md).

<details>
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ checks its current official channel and downloads a newer App in the background.
It verifies the updater signature and prepares the archive locally. The next
normal open verifies that archive again and installs it before starting services;
ordinary background preparation never replaces or restarts an active workspace.
Windows NSIS installs follow the signed-update journey on the main channel: the
App checks on launch, verifies the minisign signature, runs the installer
passively and restarts automatically. On Windows the stable channel, MSI
installs and App rollback/repair remain manual or unavailable for now, and the
installer carries no Authenticode certificate, so Windows may show a SmartScreen
prompt.
An offline or failed check keeps the installed App usable; it does not certify
that the App is current. Main points to the latest complete signed build,
not arbitrary Git HEAD. Builds without the updater need a one-time replacement.
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,13 @@ rustix = { version = "1.1.4", features = ["fs"] }
[target.'cfg(target_os = "macos")'.dev-dependencies]
objc2 = "0.6.4"
libc = "0.2"

# The Windows update regression builds a throwaway minisign key from `ring`,
# a provider-shaped prehashed signature with `blake2`, the NSIS archive with
# `zip`, and drives the pinned updater against a real window through the mock
# runtime. `base64` stays shared because the macOS signature checks use it.
[target.'cfg(target_os = "windows")'.dev-dependencies]
blake2 = "0.10"
ring = "0.17"
tauri = { version = "2.11.1", features = ["test"] }
zip = { version = "4", default-features = false }
24 changes: 23 additions & 1 deletion apps/desktop/loopx-control-plane/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,27 @@ fn main() {
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
tauri_build::AppManifest::new().commands(&["desktop_update", "desktop_update_status"]),
))
.expect("desktop build")
.expect("desktop build");

link_windows_resource_into_tests();
}

/// `tauri_build` hands its Windows resource file to `[[bin]]` targets only
/// (`cargo:rustc-link-arg-bins`), so the libtest binary of this crate links
/// without a manifest. Its `comctl32` imports are Common Controls v6 entry
/// points, which the loader then resolves against the System32 5.82 copy, and
/// the test process dies with `STATUS_ENTRYPOINT_NOT_FOUND` before `main`.
/// Attach the same resource to every linked target so `cargo test` can run on
/// Windows.
fn link_windows_resource_into_tests() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let Ok(out_dir) = std::env::var("OUT_DIR") else {
return;
};
let resource = std::path::Path::new(&out_dir).join("resource.lib");
if resource.exists() {
println!("cargo:rustc-link-search=native={out_dir}");
}
}
Loading
Loading