Skip to content

Commit 5b413e6

Browse files
authored
feat(release): verify Windows automatic updates end to end (#3240)
Add a loopback-only update-feed override and end-to-end Windows evidence for discovering, downloading, installing, and relaunching an automatic update. Generated-by: Claude Fable 5
1 parent 2bdb721 commit 5b413e6

11 files changed

Lines changed: 920 additions & 31 deletions

.github/workflows/release-desktop.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
runner: windows-2025
2626
runs-on: ${{ matrix.runner }}
2727
environment: release
28-
timeout-minutes: 45
28+
timeout-minutes: 60
2929
defaults:
3030
run:
3131
# Windows runners default to pwsh; the release steps are written once,
@@ -153,6 +153,19 @@ jobs:
153153
"${{ steps.release.outputs.exe }}" \
154154
"${{ steps.previous.outputs.exe }}"
155155
156+
- name: Build the version-bumped autoupdate installer
157+
if: matrix.platform == 'windows'
158+
run: npm run package:windows-autoupdate-next
159+
160+
# The fake-versioned artifacts live outside apps/desktop/release, so the
161+
# upload globs below can never pick them up.
162+
- name: Verify automatic update end to end
163+
if: matrix.platform == 'windows'
164+
run: |
165+
npm run verify:windows-autoupdate -- \
166+
"${{ steps.release.outputs.exe }}" \
167+
apps/desktop/release-autoupdate-next
168+
156169
- name: Upload the verified release assets
157170
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
158171
with:

.github/workflows/release-windows-check.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ on:
2121
- 'scripts/verify-windows-x64.mjs'
2222
- 'scripts/verify-windows-sandbox-e2e.mjs'
2323
- 'scripts/verify-windows-installer-lifecycle.mjs'
24+
- 'scripts/verify-windows-autoupdate.mjs'
25+
- 'scripts/package-windows-autoupdate-next.mjs'
26+
# The packaged updater's feed behavior — and the boot wiring that hands
27+
# MAKA_UPDATE_TEST_FEED to it — is only observable on this path.
28+
- 'apps/desktop/src/main/app-update-service.ts'
29+
- 'apps/desktop/src/main/runtime-host-boot.ts'
2430
- 'scripts/prepare-windows-upgrade-baseline.mjs'
2531
- 'scripts/windows-upgrade-baseline.json'
2632
- 'scripts/verify-packaged-app.mjs'
@@ -47,7 +53,7 @@ concurrency:
4753
jobs:
4854
package:
4955
runs-on: windows-2025
50-
timeout-minutes: 60
56+
timeout-minutes: 75
5157
defaults:
5258
run:
5359
shell: bash
@@ -89,3 +95,13 @@ jobs:
8995
npm run verify:windows-installer -- \
9096
"apps/desktop/release/Maka-${version}-win-x64.exe" \
9197
"${{ steps.previous.outputs.exe }}"
98+
99+
- name: Build the version-bumped autoupdate installer
100+
run: npm run package:windows-autoupdate-next
101+
102+
- name: Verify automatic update end to end
103+
run: |
104+
version="$(node -p "require('./apps/desktop/package.json').version")"
105+
npm run verify:windows-autoupdate -- \
106+
"apps/desktop/release/Maka-${version}-win-x64.exe" \
107+
apps/desktop/release-autoupdate-next

apps/desktop/src/main/__tests__/app-update-service.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, test } from 'node:test';
44
import type { AppUpdater } from 'electron-updater';
55
import {
66
createAppUpdateService,
7+
resolveUpdateFeedOverride,
78
type AppUpdateInstallRequest,
89
type AppUpdateStatus,
910
} from '../app-update-service.js';
@@ -106,6 +107,7 @@ function createHarness(input: {
106107
>;
107108
mockLatestVersion?: string;
108109
mockState?: 'available' | 'downloading' | 'downloaded';
110+
testFeedUrl?: string;
109111
} = {}) {
110112
const updater = input.updater ?? new FakeUpdater();
111113
const clock = input.clock ?? new FakeClock();
@@ -121,6 +123,7 @@ function createHarness(input: {
121123
: { kind: 'prepared', rollback() {} }),
122124
mockLatestVersion: input.mockLatestVersion,
123125
mockState: input.mockState,
126+
testFeedUrl: input.testFeedUrl,
124127
});
125128
return { clock, service, updater };
126129
}
@@ -150,6 +153,56 @@ describe('AppUpdateService', () => {
150153
assert.equal(clock.pending().length, 0);
151154
});
152155

156+
test('routes the feed to a loopback generic provider when the test override is set', () => {
157+
const { updater } = createHarness({ testFeedUrl: 'http://127.0.0.1:8443/feed' });
158+
assert.deepEqual(updater.feed, {
159+
provider: 'generic',
160+
url: 'http://127.0.0.1:8443/feed',
161+
});
162+
});
163+
164+
test('rejects a non-loopback test feed instead of falling back to production', () => {
165+
// A mistyped override must never silently install from the real GitHub
166+
// feed: construction fails closed.
167+
assert.throws(
168+
() => createHarness({ testFeedUrl: 'https://evil.example/feed' }),
169+
TypeError,
170+
);
171+
});
172+
173+
test('resolveUpdateFeedOverride accepts exactly loopback http URLs', () => {
174+
assert.equal(resolveUpdateFeedOverride(undefined), undefined);
175+
assert.equal(resolveUpdateFeedOverride(''), undefined);
176+
assert.deepEqual(resolveUpdateFeedOverride('http://127.0.0.1:1'), {
177+
provider: 'generic',
178+
url: 'http://127.0.0.1:1/',
179+
});
180+
assert.deepEqual(resolveUpdateFeedOverride('http://127.0.0.1:65535/updates'), {
181+
provider: 'generic',
182+
url: 'http://127.0.0.1:65535/updates',
183+
});
184+
const rejected = [
185+
'not-a-url',
186+
'file:///C:/feed',
187+
'https://127.0.0.1:1', // https is not loopback-harness shaped
188+
'http://localhost:1', // alias resolution is not identity
189+
'http://127.0.0.2:1', // other loopback addresses stay rejected
190+
'http://[::1]:1', // IPv6 loopback stays rejected: one accepted shape only
191+
'http://127.0.0.1', // no port: cannot be an ephemeral harness server
192+
'http://u:p@127.0.0.1:1', // userinfo confusion
193+
'http://127.0.0.1.evil.example:1', // hostname prefix confusion
194+
'http://127.0.0.1:1/x?y=1', // query smuggling
195+
'http://127.0.0.1:1/x#frag',
196+
];
197+
for (const raw of rejected) {
198+
assert.throws(
199+
() => resolveUpdateFeedOverride(raw),
200+
TypeError,
201+
`expected rejection: ${raw}`,
202+
);
203+
}
204+
});
205+
153206
test('does not overlap checks and cannot re-arm after disposal', async () => {
154207
const updater = new FakeUpdater();
155208
let settleCheck!: (value: unknown) => void;

apps/desktop/src/main/app-update-service.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ interface AppUpdateServiceDeps {
6767
currentVersion: string;
6868
isPackaged: boolean;
6969
updater?: AppUpdater;
70+
/**
71+
* Harness-only feed override (`MAKA_UPDATE_TEST_FEED`); see
72+
* {@link resolveUpdateFeedOverride} for the exact accepted shape.
73+
*/
74+
testFeedUrl?: string;
7075
mockLatestVersion?: string;
7176
mockState?: 'available' | 'downloading' | 'downloaded';
7277
onStatusChange?: (status: AppUpdateStatus) => void;
@@ -93,6 +98,58 @@ const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
9398
*/
9499
const UPDATE_CHECK_ON_FOCUS_MIN_INTERVAL_MS = 15 * 60 * 1000;
95100

101+
/**
102+
* Harness-only override for the update feed (`MAKA_UPDATE_TEST_FEED`).
103+
*
104+
* Accepts exactly `http://127.0.0.1:<port>[/path]` and maps it to a generic
105+
* provider so the end-to-end Windows auto-update verification can serve a
106+
* candidate installer plus `latest.yml` from a loopback HTTP server. Anything
107+
* else set — a remote host, `localhost`, another loopback alias, HTTPS,
108+
* userinfo, a query string, or a malformed URL — throws: a mistyped override
109+
* must never silently fall back to the production GitHub feed, because a test
110+
* run quietly installing a real release is exactly the failure this shape
111+
* exists to prevent.
112+
*
113+
* Security posture (this is not an update-hijack vector): setting an
114+
* environment variable on the app's process already requires code execution
115+
* as the same user, and the per-user NSIS install model means that user can
116+
* rewrite the installation directory directly — the override grants no
117+
* capability across any privilege boundary. Loopback-only keeps even that
118+
* same-user surface minimal: the feed must be a process listening on this
119+
* machine. With the variable unset the feed configuration is byte-identical
120+
* to production, and update signature verification (once a certificate
121+
* exists) applies to overridden feeds exactly as it does to the GitHub feed —
122+
* nothing here relaxes it.
123+
*/
124+
export function resolveUpdateFeedOverride(
125+
raw: string | undefined,
126+
): { provider: 'generic'; url: string } | undefined {
127+
if (raw === undefined || raw === '') return undefined;
128+
let url: URL;
129+
try {
130+
url = new URL(raw);
131+
} catch {
132+
throw new TypeError(
133+
`MAKA_UPDATE_TEST_FEED is not a URL: ${JSON.stringify(raw)}`,
134+
);
135+
}
136+
if (
137+
url.protocol !== 'http:' ||
138+
url.hostname !== '127.0.0.1' ||
139+
url.port === '' ||
140+
url.username !== '' ||
141+
url.password !== '' ||
142+
url.search !== '' ||
143+
url.hash !== ''
144+
) {
145+
throw new TypeError(
146+
'MAKA_UPDATE_TEST_FEED must be http://127.0.0.1:<port>[/path] ' +
147+
`(got ${JSON.stringify(raw)})`,
148+
);
149+
}
150+
return { provider: 'generic', url: url.toString() };
151+
}
152+
96153
function normalizeVersion(version: string): string {
97154
return version.trim().replace(/^v/i, '');
98155
}
@@ -241,11 +298,16 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
241298
updater.autoInstallOnAppQuit = false;
242299
updater.allowPrerelease = false;
243300
updater.logger = null;
244-
updater.setFeedURL({
245-
provider: 'github',
246-
owner: 'Maka-Agent',
247-
repo: 'maka-agent',
248-
});
301+
// The override changes the feed URL and nothing else: every other updater
302+
// setting and the whole status machine behave identically under it, so what
303+
// the loopback harness verifies is what production runs.
304+
updater.setFeedURL(
305+
resolveUpdateFeedOverride(deps.testFeedUrl) ?? {
306+
provider: 'github',
307+
owner: 'Maka-Agent',
308+
repo: 'maka-agent',
309+
},
310+
);
249311

250312
updater.on('checking-for-update', () => {
251313
publish({ state: 'checking', currentVersion: deps.currentVersion });

apps/desktop/src/main/runtime-host-boot.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ const updateMockState =
420420
const updateService = createAppUpdateService({
421421
currentVersion: app.getVersion(),
422422
isPackaged: app.isPackaged,
423+
testFeedUrl: process.env.MAKA_UPDATE_TEST_FEED,
423424
mockLatestVersion: process.env.MAKA_UPDATE_MOCK_VERSION,
424425
mockState: updateMockState,
425426
onStatusChange: (status) =>

docs/windows-support.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Windows support baseline
22

3-
Windows is an active enablement target, not a fully supported Maka platform yet. The CLI and Electron desktop application can run from source, and release workflows produce a verified unsigned Windows x64 preview. The x64 package includes an AppContainer sandbox for restricted managed execution; signing, the complete adversarial sandbox matrix, automatic updates, and computer-use guarantees remain incomplete. Progress is tracked in [GitHub issue #2142](https://github.com/maka-agent/maka-agent/issues/2142).
3+
Windows is an active enablement target, not a fully supported Maka platform yet. The CLI and Electron desktop application can run from source, and release workflows produce a verified unsigned Windows x64 preview. The x64 package includes an AppContainer sandbox for restricted managed execution, and automatic updates are verified end to end in CI on the unsigned preview channel; signing, the complete adversarial sandbox matrix, and computer-use guarantees remain incomplete. Progress is tracked in [GitHub issue #2142](https://github.com/maka-agent/maka-agent/issues/2142).
44

55
## Install the Windows x64 preview
66

@@ -25,8 +25,15 @@ Only use Windows assets attached to a Maka GitHub Release. The NSIS installer is
2525

2626
The release gate installs a pinned v0.1.9 build, fully smokes it, upgrades the same installation to
2727
the candidate, fully smokes the candidate, waits for installed processes to exit, and runs the real
28-
uninstaller. This proves a closed-app upgrade and uninstall path. It does not prove automatic update,
29-
running-app upgrade, persisted business-data migration, or rollback after a mid-install failure.
28+
uninstaller. A second gate proves the automatic, running-app upgrade path: the installed candidate,
29+
running, discovers a newer build through its packaged electron-updater against a loopback test feed,
30+
downloads it in the background, hands off to the NSIS installer, relaunches as the new version, and
31+
passes the full packaged smoke — with the feed requests (including the differential-download probe),
32+
the `downloaded` state and its exact version pair, and the final installed version asserted
33+
individually; transient states such as `checking` and `downloading` are not individually asserted. What is still not proven: update signature verification (no Authenticode
34+
certificate yet — the feed configuration for the production GitHub channel is pinned by unit tests
35+
and exercised routinely on real releases instead), persisted business-data migration, and rollback
36+
after a mid-install failure.
3037

3138
To uninstall, use **Settings → Apps → Installed apps → Maka → Uninstall**. Back up any important
3239
workspace data first; the preview does not yet claim installer rollback or migration guarantees.
@@ -51,8 +58,12 @@ workspace data first; the preview does not yet claim installer rollback or migra
5158
`winget install BurntSushi.ripgrep.MSVC`,并在 `PATH` 更新后重启 Maka。
5259

5360
发布门禁会安装固定的 v0.1.9、执行完整 smoke、在同一目录升级候选版本、再次完整 smoke、等待安装目录内
54-
进程退出,并运行真实卸载器。这证明关闭应用后的升级与卸载路径,不证明自动更新、运行中升级、业务数据迁移,
55-
也不证明安装中途失败后的 rollback。
61+
进程退出,并运行真实卸载器。另一个门禁证明**运行中的自动更新路径**:已安装且正在运行的候选版本通过打包的
62+
electron-updater 从 loopback 测试 feed 发现新版本、后台下载、交接给 NSIS 安装器、以新版本自动重启并通过
63+
完整打包 smoke——feed 请求(含差量下载探测)、`downloaded` 状态及其精确版本对、最终安装版本均逐项断言;
64+
`checking`/`downloading` 等瞬态不逐项断言。仍未证明的是:更新签名校验(尚无
65+
Authenticode 证书;生产 GitHub 通道的 feed 配置由单测钉死,并在每次真实 release 中例行使用)、业务数据
66+
迁移,以及安装中途失败后的 rollback。
5667

5768
卸载入口为 **设置 → 应用 → 已安装的应用 → Maka → 卸载**。预览版尚未承诺安装器 rollback 或数据迁移,
5869
请先备份重要 workspace 数据。
@@ -69,7 +80,7 @@ The initial target is a native Windows 11 x64 development environment with:
6980
- WebView/runtime components installed by a current Windows 11 installation;
7081
- Windows Developer Mode or elevation only for tests that create file symlinks. Normal CLI and desktop startup must not require either.
7182

72-
Windows 10, Windows on Arm, automatic updates, the final sandbox support declaration, and computer-use are not covered by the current support target. Packaged installation is available only as the unsigned Windows 11 x64 preview described above.
83+
Windows 10, Windows on Arm, signed automatic updates, the final sandbox support declaration, and computer-use are not covered by the current support target. Packaged installation is available only as the unsigned Windows 11 x64 preview described above; its automatic-update path is CI-verified but unsigned.
7384

7485
## Reproducible checks
7586

@@ -148,6 +159,9 @@ The root test timeout is tracked separately from individual test failures. Phase
148159
- Restricted managed profiles use the packaged AppContainer broker when available and fail closed
149160
when the native capability or requested policy is unavailable.
150161
- Computer-use has no Windows backend.
151-
- The Windows x64 NSIS installer is unsigned and there is no supported automatic-update channel.
162+
- The Windows x64 NSIS installer is unsigned. The in-app automatic-update path (electron-updater →
163+
NSIS handoff → relaunch) is verified end to end in CI against a loopback feed; the production
164+
GitHub feed configuration is pinned by unit tests. Updates are not signature-verified until an
165+
Authenticode certificate lands.
152166

153167
Do not describe Windows as released or fully supported until the support criteria in issue #2142 are complete for the claimed support tier.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
"package:windows-x64": "node scripts/package-windows-x64.mjs",
5454
"verify:windows-x64": "node scripts/verify-windows-x64.mjs",
5555
"verify:windows-installer": "node scripts/verify-windows-installer-lifecycle.mjs",
56+
"package:windows-autoupdate-next": "node scripts/package-windows-autoupdate-next.mjs",
57+
"verify:windows-autoupdate": "node scripts/verify-windows-autoupdate.mjs",
5658
"astryx:theme": "node scripts/build-astryx-theme.mjs",
5759
"astryx:surface-inventory": "node scripts/check-astryx-surface-inventory.mjs",
5860
"astryx:surface-inventory:write": "node scripts/generate-astryx-surface-inventory.mjs",

0 commit comments

Comments
 (0)