From 6a537ac616a3aca7bdf49c4c4c823f9af9b97291 Mon Sep 17 00:00:00 2001 From: David Whatley Date: Sat, 1 Aug 2026 19:10:15 -0400 Subject: [PATCH 01/27] docs(windows): port release and proof guidance from #999 Based-on: nsxdavid/ADE#999 --- WINDOWS_PORT.md | 430 ++++++++++++++++++ docs/ARCHITECTURE.md | 26 +- docs/README.md | 3 +- docs/development/windows-port-lane.md | 23 +- docs/features/chat/composer-and-ui.md | 9 +- docs/features/computer-use/README.md | 8 + docs/features/computer-use/app-control.md | 18 +- .../onboarding-and-settings/README.md | 12 +- .../desktop-auto-update.md | 29 ++ docs/features/remote-runtime/README.md | 37 +- docs/features/sync-and-multi-device/README.md | 40 +- .../sync-and-multi-device/crdt-model.md | 22 +- .../features/terminals-and-sessions/README.md | 16 +- .../pty-and-sessions.md | 18 +- docs/logging.md | 9 + docs/playbooks/windows-signed-release.md | 253 +++++++++++ 16 files changed, 893 insertions(+), 60 deletions(-) create mode 100644 WINDOWS_PORT.md create mode 100644 docs/playbooks/windows-signed-release.md diff --git a/WINDOWS_PORT.md b/WINDOWS_PORT.md new file mode 100644 index 000000000..a28ecffb4 --- /dev/null +++ b/WINDOWS_PORT.md @@ -0,0 +1,430 @@ +# Windows port evaluation + +## Executive summary + +ADE does not need a ground-up Windows port. Most platform foundations already +exist: Windows named pipes, PowerShell/cmd PTYs, Git for Windows resolution, +process-tree termination, Windows native provider packages, `node-pty`, a +vendored x64 `crsqlite.dll`, NSIS packaging, CLI wrappers, and extensive +artifact validation. + +The repository history confirms this. Windows foundations landed in April-May +2026 (`#186`, `#213`, and `#281`), and the release was deliberately made +macOS-only on June 12 in `#561`. However, simply uncommenting the Windows +workflow would not produce a dependable release. + +Recommended direction: + +- Target Windows 10/11 x64 using the existing per-user NSIS installer. +- Ship a bounded "Windows x64 preview" PR instead of promising complete + platform parity. +- Explicitly defer Windows ARM64, Windows as a remotely installable ADE brain, + native Windows computer use, and iOS Simulator support. + +The highest risk is the packaged background brain lifecycle, not Electron +rendering or TypeScript compilation. + +## Implementation status on `windows-native-build` + +The code changes recommended by this evaluation are now implemented on the +working branch: + +- The Windows brain runs through a per-user/channel current-user startup entry + and a BOM-marked PowerShell launcher that restores the complete resolved + runtime environment without requiring administrator access. Legacy Scheduled + Task cleanup fails closed, and runtime/desktop-bridge named pipes are isolated by canonical ADE + home, channel, and current user. Windows IPC servers explicitly retain + Node's intended-user-only named-pipe access flags; effective cross-account + access remains a clean-VM proof gate. +- Tracked CLI continuation uses structured command/argv/env descriptors on + Windows for Claude, Codex, Cursor, OpenCode, and Droid. App Control likewise + uses structured Windows launches for direct Electron/package scripts and + platform-specific shell fallbacks. Fresh provider intent is materialized on + the runtime that owns the lane, so a Windows renderer cannot send + PowerShell wrappers or Windows skill paths to a pinned macOS/Linux runtime. +- The Windows x64 package contains every supported Darwin/Linux remote-runtime + sidecar. Required `win-unpacked` package smoke validates the CLI/TUI, + ConPTY, bundled Claude/Codex/OpenCode binaries, Cursor native helpers, + Cursor/Droid SDK entry points, update authority, and a real `crsqlite.dll` + CRR mutation. The NSIS uninstaller stops and removes the Windows background + service, then removes only the terminal shim and user `PATH` entry owned by + that installation. Installing the generated NSIS package remains a separate + external gate. +- Required pull-request CI now builds an unsigned NSIS preview on + `windows-latest`. Production Windows build and public release are separately + gated; the signed path requires a pinned Authenticode identity, matching + signer for installer and `ADE.exe`, and a trusted RFC3161 timestamp. +- The updater authority follows the repository that built the package. The + source default remains upstream `arul28/ADE`, while CI passes + `ADE_RELEASE_REPOSITORY=${{ github.repository }}` for fork builds. Windows + download links and release assets remain disabled until the public gates + are explicitly enabled. +- Windows chrome, AppUserModelID, microphone-denial guidance, sync health, and + platform-aware copy/navigation are implemented. macOS-native Notch, + computer-use, and iOS Simulator actions are hidden or capability-blocked + while App Control, Browser, and proof ingestion remain available. +- The Windows developer loop now uses a per-user named pipe, invokes local + JavaScript CLI entry points instead of fragile global `.cmd` shims, strips + inherited runtime parent/idle shutdown controls, and waits for tsup's + explicit successful-build signal before starting or restarting Electron. + Runtime startup remains bounded at 30 seconds and reports an early child + exit immediately. The launcher records whether it created the detached + runtime and shuts down only that owned runtime when Electron exits or the + developer interrupts the command, so a failed or closed dev session does + not leave a polling runtime behind. +- Windows background probes and worker processes are created with hidden + console windows. This includes background-service status checks that the + desktop polls every two seconds, provider/auth/usage/Git/Tailscale probes, + runtime and PTY workers, and service install/uninstall operations. The Unix + `ps` resource sampler now reports `unsupported-platform` on Windows without + launching a process. These protections address a host-loss incident where + visible PowerShell console windows were repeatedly created and continued + after the Electron window closed. +- Windows sync-host startup now rejects stale lock files when the recorded PID + has been reused by a different executable and records process start time for + future locks. The projectless brain uses the same 8787-8999 fallback range + as project-scoped sync instead of waiting forever on 8787. This matters on + Windows hosts where Tailscale or another local service already owns 8787. + +No source blocker is currently known for the bounded Windows x64 desktop +preview. The remaining release work is external proof: clean standard-user +Windows 10/11 install/logoff/reboot/uninstall, +Stable+Beta and two-user isolation, physical-iPhone CRR/firewall testing, +provider/PTY special-character coverage, supported macOS/Linux remote +bootstrap, and signed-installer launch/relaunch/background-brain recovery. +Do not enable `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` or +`VITE_ADE_WINDOWS_DOWNLOAD_ENABLED=1` before those checks pass. + +Maintainers can follow +[`docs/playbooks/windows-signed-release.md`](docs/playbooks/windows-signed-release.md) +for signing, draft-release verification, publication, +and website-enable procedure. + +Four source follow-ups are explicitly outside this preview boundary: +`ade brain update` continues to reject Windows because Windows as a standalone +remote brain is deferred; `ade doctor` does not yet discover an installed +Windows desktop version. None is required to install and run the local Windows +desktop preview, but each should be resolved before calling Windows a +first-class remote-brain/operations platform. + +## Current readiness + +| Area | Current state | Required work | +| --- | --- | --- | +| Electron/NSIS packaging | Required unsigned PR package job, owned-integration uninstall cleanup, and signed release path are implemented | Clean-VM installer proof | +| Native dependencies | `win-unpacked` smoke loads ConPTY/provider payloads and performs a real `crsqlite.dll` CRR mutation | Repeat from an installed signed build | +| Projects, lanes, Git, files | Windows-aware paths, Git, and junction code exist | Clean-VM functional testing | +| Terminal/PTY | Structured Windows launch/resume, runtime-host materialization, and taskkill cleanup are implemented | Installed provider/ConPTY matrix | +| Background brain | No-admin per-user/channel startup launcher and NSIS uninstall cleanup are implemented | Logoff/reboot/update/uninstall proof | +| Updater | Fork authority and fail-closed signing/publication gates are implemented | Validate automatic updating after two signed Windows releases exist | +| Windows developer loop | Per-user runtime pipe, successful-build-gated Electron launch, hidden background probes, and owned-runtime cleanup are implemented and host-tested | Repeat from a clean clone | +| Sync/iPhone pairing | Intended to work | CRR roundtrip and firewall testing | +| Built-in browser/proof ingest | Mostly platform-neutral | Windows Hello, download, and security testing | +| Native computer use | macOS-only by design; capability-gated on Windows | Separate native Windows project | +| iOS Simulator/Xcode Preview | macOS-only and hidden on Windows | No Windows work required | +| Windows remote brain host | Explicitly rejected | Separate project | +| Windows ARM64 | Native payloads incomplete | Separate project | + +The repository's own +[Windows port document](docs/development/windows-port-lane.md#already-in-this-branch-do-not-re-implement) +accurately lists the foundations, but its release claims are stale. + +## Original release-blocking findings + +The sections below preserve the static-evaluation rationale that shaped the +implementation. Each release-blocking source finding below has an +implementation on this branch; effective named-pipe access, clean-VM +login-startup behavior, and signed-update behavior still require the +external proof gates above. + +### 1. The scheduled background brain drops required environment variables + +This is the most serious defect. + +The service command carries `ELECTRON_RUN_AS_NODE=1`, `NODE_PATH`, channel, ADE +home, and runtime configuration in +[`common.ts`](apps/ade-cli/src/serviceManager/common.ts). However, +`renderWindowsCommand()` serializes only the executable and arguments. + +The resulting task registers roughly: + +```text +ADE.exe cli.cjs serve +``` + +without `ELECTRON_RUN_AS_NODE=1`. On a clean machine this can reopen the +Electron GUI instead of starting the CLI brain. Because ADE expects the service +to own the primary runtime pipe, this can leave the desktop without its normal +synchronized runtime. + +The PR should install a dedicated service launcher or safely serialize all +required environment variables, then prove install, start, logoff/logon, +update, and uninstall on a clean machine without Node installed. + +### 2. Background-service registration must not require administrator access + +Windows Task Scheduler rejects task creation from a standard user. ADE's +per-user installer must not require elevation merely to start its background +runtime at login. + +The implementation now writes a channel- and user-qualified value under the +current user's normal Windows startup registry key. A hidden PowerShell +supervisor starts the packaged runtime, records its process identity, and lets +status and uninstall stop only that ADE-owned process tree. Old Scheduled Task +registrations are removed during migration. + +### 3. The Windows package cannot satisfy its own validator + +The Windows validator requires Darwin and Linux x64/arm64 remote-runtime +sidecars in +[`validate-win-artifacts.mjs`](apps/desktop/scripts/validate-win-artifacts.mjs), +but [`package.json`](apps/desktop/package.json) copies only the Darwin +artifacts. + +A re-enabled `dist:win` should therefore fail post-package validation. + +The product decision is either: + +- Include all four sidecar pairs so Windows can bootstrap existing macOS/Linux + remote runtimes; or +- Reduce the Windows remote-bootstrap contract, gate the feature, and update + the validator accordingly. + +Including everything is simpler for a first preview but increases installer +size. On-demand, checksummed sidecar downloads would be cleaner later. + +### 4. Provider resume and App Control commands still contain POSIX syntax + +Fresh provider launches are mostly structured and Windows-aware. Resume and +fallback paths frequently generate shell strings instead. + +Examples include OpenCode environment assignments and Droid resume commands in +[`cliLaunch.ts`](apps/desktop/src/shared/cliLaunch.ts). App Control's +package-script rewrite emits `PATH=:$PATH` and POSIX quoting in +[`appControlLaunchCommand.ts`](apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts), +even though the command is later typed into PowerShell or cmd. + +These paths will fail with some configurations and paths containing spaces, +quotes, `$`, `%`, `&`, or backticks. + +The durable fix is a structured invocation contract: + +```ts +{ + command, + args, + env, + displayCommand, +} +``` + +Shell text should remain only for commands that genuinely require a shell, +with separate PowerShell and cmd quoting. + +### 5. Windows named-pipe identity is not sufficiently isolated + +The machine pipe name is derived only from the basename of ADE home in +[`machineLayout.ts`](apps/ade-cli/src/services/projects/machineLayout.ts). The +default `.ade` therefore produces the same global named-pipe name for every +Windows user. + +The PR should derive pipe names from the canonical ADE home, channel, and +current user SID/hash, and verify that the pipe ACL is limited to the intended +user. Test two Windows users and Stable/Beta side by side. + +### 6. Release and update configuration is disabled or points at upstream + +The Windows build, download, validation, and upload blocks are commented out +in [`.github/workflows/release-core.yml`](.github/workflows/release-core.yml). +There is also no Windows runner in normal PR CI. + +At evaluation time, the packaged updater was hardcoded to upstream +`arul28/ADE` in: + +- [`apps/desktop/package.json`](apps/desktop/package.json) +- [`apps/desktop/resources/app-update.yml`](apps/desktop/resources/app-update.yml) +- [`autoUpdateService.ts`](apps/desktop/src/main/services/updates/autoUpdateService.ts) + +A Windows build published by another fork would check upstream for updates, +where the corresponding Windows artifacts might not exist. + +The distribution repository should be build metadata generated from +`github.repository`. The production `setFeedURL` override should be removed or +centralized. Electron-builder recommends using its generated `app-update.yml`; +its NSIS target already supports Windows auto-update and `latest.yml` +metadata. See the +[electron-builder auto-update documentation](https://www.electron.build/docs/features/auto-update/). + +### 7. Public signing currently fails open + +The existing configuration supports Authenticode, but missing secrets result +in an unsigned installer. The validator only checks signatures when an opt-in +flag is set. + +Recommended policy: + +- PR CI may build an unsigned artifact. +- Release CI must fail if signing is unavailable. +- Verify the installer and installed `ADE.exe`, publisher identity, and RFC + 3161 timestamp. +- Publish the installer, blockmap, and `latest.yml` atomically. +- Use Microsoft Artifact Signing or a stable organizational Authenticode + certificate. + +Electron-builder exposes `forceCodeSigning` specifically to prevent silently +unsigned production builds. See the +[electron-builder signing documentation](https://www.electron.build/docs/features/code-signing/). + +Signing will not automatically eliminate every early SmartScreen prompt. +Microsoft notes that even valid OV/EV-signed applications can be classified as +unrecognized until publisher/file reputation develops; unsigned releases must +rebuild reputation for every version. See +[Microsoft's SmartScreen guidance](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/smartscreen-reputation). + +## Product and UX work + +The build PR should also include a focused platform pass: + +- Make the Windows title bar explicit. + [`main.ts`](apps/desktop/src/main/main.ts) unconditionally uses + `hiddenInset`, macOS traffic-light positioning, and a renderer header with + 80 px of left padding. Verify caption buttons, dragging, double-click + maximize, Snap Layouts, and DPI scaling. +- Hide or clearly disable iOS Simulator, Xcode Preview, native Notch, and local + OS computer-use actions. +- Keep browser/App Control capture and proof-file ingestion enabled where + supported. +- Replace visible "This Mac", "Reveal in Finder", `Command` key, and macOS + Keychain wording with platform-aware labels. Preserve the internal + `this-mac` identifier because it is a protocol/persistence invariant. +- Update the website. + [`DownloadPage.tsx`](apps/web/src/app/pages/DownloadPage.tsx) currently says + Windows installers are not published. +- Add Windows-specific microphone denial guidance; the current flow treats + non-macOS access as automatically granted. +- Add a Windows sync-health surface. A missing or unloadable `crsqlite.dll` + currently degrades sync primarily through logs. +- Test Windows Defender Firewall behavior for LAN phone pairing and provide + actionable relay/Tailscale guidance. +- Add `setAppUserModelId` if packaged toast identity proves unreliable. + +The supported OS floor should be Windows 10/11 x64. ADE uses Electron 41, +while Electron 23 and newer require Windows 10 or later. See +[Electron platform support](https://www.electronjs.org/docs/latest/breaking-changes). + +## Recommended PR boundary + +A reviewable first submission should be titled along the lines of +"Add Windows x64 preview build" and contain the following work. + +### 1. Runtime correctness + +- Fix the scheduled-task environment, channel naming, and locale-safe status. +- Use user/channel-scoped named pipes. +- Introduce structured provider resume commands. +- Fix App Control's Windows launch handling. +- Generate sync singleton recovery commands that do not suggest `launchctl` + or `/bin/kill`. + +### 2. Packaging and CI + +- Resolve the remote-sidecar mismatch. +- Add `windows-latest` PR packaging and smoke validation. +- Load `crsqlite.dll` and perform a minimal CRR operation during packaged + smoke. +- Probe the bundled CLI/TUI, PTY, and provider executables. +- Keep the target x64-only. + +### 3. Release and updates + +- Parameterize the fork's release authority. +- Restore the release/publish workflow. +- Fail closed on production signing. +- Validate installed N-to-N+1 signed updating after two releases exist. + +### 4. Platform UX and documentation + +- Add an explicit Windows title bar and capability-driven navigation. +- Use neutral copy and platform-aware shortcuts. +- Add Windows download and analytics links. +- Correct stale architecture and Windows-port documentation. + +Public download enablement should remain gated until the signed installer +passes the clean-host checks. If certificate provisioning is not ready, the PR +can still produce an unsigned internal CI artifact while leaving public +publishing disabled. + +## Merge gates + +At minimum: + +- Test Windows 10 22H2 and Windows 11 x64 clean standard-user VMs. +- Install without Node or administrator rights. +- Verify first launch, app restart, logoff/logon, and uninstall/reinstall. +- Install Stable and Beta simultaneously. +- Open/create a project; create/delete a lane; exercise worktree, junction, + commit, rebase, and conflict flows. +- Exercise PowerShell and cmd PTYs: Unicode, resize, Ctrl+C, cancellation, and + child-tree cleanup. +- Test fresh launch and resume for Claude, Codex, Cursor, Droid, and OpenCode. +- Test paths and prompts containing spaces, Unicode, quotes, `$`, `%`, and + `&`. +- Load packaged `crsqlite.dll` and complete a bidirectional Windows + desktop-to-physical-iPhone CRR sync. +- Use the Windows desktop to control an existing macOS/Linux remote runtime. +- Exercise the built-in browser, downloads, proof ingest, and App Control CDP + capture. +- Test `ade://` cold/hot deep links, file associations, the PATH wrapper, and + uninstall cleanup. +- Test DPI at 100/125/150/200 percent, multiple monitors, Snap Layouts, high + contrast, and keyboard navigation. + +## Explicit follow-ups + +These should not block the first Windows desktop build: + +- Windows as a remotely installable ADE brain. +- Native Windows computer use using Windows Graphics Capture/UI Automation. +- Signed N-to-N+1 automatic-update testing, including cache/retry/relaunch, + scheduled-task repair, data preservation, and rejection of tampered or + incorrectly signed updates. +- Windows ARM64 after all native/provider payloads are available. +- Windows resource telemetry and general orphan-agent recovery. + +## Readiness and uncertainty + +- The source and CI support a credible internal Windows x64 preview. +- Public Windows x64 readiness requires upstream signing configuration and the + external proof gates below. +- Largest uncertainty: installed, signed runtime behavior across clean Windows + hosts and updates. + +The initial assessment was a read-only static evaluation. Implementation and +targeted automated validation have since been completed on this branch. +A full local NSIS package still requires the CI-produced Darwin/Linux runtime +sidecars; the required Windows CI job materializes them before packaging. +No claim is made here that the external clean-VM checks or automatic-update +follow-up have passed. + +## Automated validation observed + +The source implementation was validated on Windows with: + +- A bounded no-GUI lifecycle proof that started an isolated hidden runtime + while this host's Tailscale service owned port 8787, connected over its + named pipe, requested graceful shutdown, and confirmed that the pipe was + released. +- Desktop typecheck, lint, build, documentation validation, web typecheck and + build. +- The required Windows release contract, updater, packaging-smoke, + CR-SQLite, ConPTY, App Control, microphone, window-chrome, preload, sync UI, + provider-launch, and platform-copy focused suites. +- ADE CLI typecheck/build, 328 CLI tests, 59 service-manager tests, and 1,045 + TUI tests. +- `git diff --check`. + +The legacy full test suites still contain Windows-host baseline failures in +POSIX-only fixtures, Unix-socket browser tests, chmod assertions, and several +SQLite teardown races. Focused Windows production-path tests are green, but +those baseline failures should be cleaned up in follow-up work so the entire +local suite is signal-bearing on Windows. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67425a653..c1302b007 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,7 +114,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This **Run modes:** -- **Brain** — the normal mode. Boots the multi-project JSON-RPC server, hosts the per-project services on demand, serves sync, and listens on the channel's local endpoint (POSIX: `$ADE_HOME/sock/ade.sock`; Windows: a named pipe under `\\.\pipe\ade-`, with the hash derived in `apps/desktop/src/shared/adeRuntimeIpc.ts`). On POSIX the headless RPC socket directory is created `0700` and the socket itself chmodded `0600` so only the owning user can connect (named pipes skip the chmod). Installable / removable as a login service with `ade brain start` / `ade brain stop` (per-platform installers in `apps/ade-cli/src/serviceManager/`). +- **Brain** — the normal mode. Boots the multi-project JSON-RPC server, hosts the per-project services on demand, serves sync, and listens on the channel's local endpoint (POSIX: `$ADE_HOME/sock/ade.sock`; Windows: `\\.\pipe\ade-runtime--`). `machineLayout.ts` derives the Windows hash from the canonical ADE home, runtime channel/service identity, and current Windows user identity (SID when available), so users and Stable/Beta/Alpha installs cannot collide. On POSIX the headless RPC socket directory is created `0700` and the socket itself chmodded `0600` so only the owning user can connect (named pipes skip the chmod). Installable / removable as a login service with `ade brain start` / `ade brain stop` (per-platform installers in `apps/ade-cli/src/serviceManager/`). - **Manual runtime (`ade runtime run`)** — starts a foreground runtime process on an explicit endpoint. Sync is always off so it cannot claim brain authority; use a separate `ADE_HOME` when you also want full machine-state isolation. - **Single-session CLI** — `ade ` connects to the local brain over the machine endpoint, dispatches one project-scoped action, and exits. With `--headless`, the CLI bootstraps a project's services directly from the repository instead of going through the machine brain — used in CI and for one-off scripts. - **SSH stdio bridge (`ade rpc --stdio`)** — runs a single-session JSON-RPC runtime over stdin/stdout. This is what desktop's `RemoteConnectionPool` execs over SSH after `bootstrapRemoteRuntime` has uploaded a matching `ade-` binary. Exits when the SSH channel closes. @@ -216,7 +216,7 @@ It installs `ade-win32-x64.exe` as `%ADE_HOME%\bin\ade.exe`, transactionally sta **Desktop bridge endpoint.** The ADE runtime runs `apps/ade-cli/dist/cli.cjs` under `ELECTRON_RUN_AS_NODE=1`, so it has no access to renderer-side Electron APIs (`WebContentsView`, `nativeImage`, `session`, …). A small set of services own real desktop UI and therefore cannot live in the runtime — most notably `BuiltInBrowserService`, which drives the Browser pane's `WebContentsView`. The desktop main process hosts those services and exposes them to the runtime over a side-channel JSON-RPC Unix-domain socket / named pipe. -The endpoint path is resolved by `apps/ade-cli/src/services/projects/machineLayout.ts`: `/sock/desktop-bridge.sock` on macOS / Linux (e.g. `~/.ade/sock/desktop-bridge.sock` stable, `~/.ade-beta/sock/desktop-bridge.sock` beta), and `\\.\pipe\ade-desktop-bridge[-]` on Windows. `ADE_DESKTOP_BRIDGE_SOCKET_PATH` overrides it for dev launches against a non-default ADE home. The server lives in `apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts`, wired up from `main.ts` right after `builtInBrowserService` is constructed and torn down with it on app shutdown. The runtime-side proxy is `apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts`; `createAdeRuntime` in `bootstrap.ts` assigns it to `runtime.builtInBrowserService` so the existing action registry slot resolves transparently (skipped when `runtimeProfile === "chat"`). Both sides share the same method allowlist: `getStatus, requestOriginAccess, claim, startSession, listSessions, endSession, showPanel, setBounds, navigate, createTab, switchTab, closeTab, reload, goBack, goForward, stop, observe, getTrace, click, typeText, dispatchKey, scroll, fill, clear, wait, startInspect, stopInspect, captureScreenshot, selectPoint, selectCurrent, clearSelection`. Profile diagnostics and permission administration are deliberately absent. +The endpoint path is resolved by `apps/ade-cli/src/services/projects/machineLayout.ts`: `/sock/desktop-bridge.sock` on macOS / Linux (e.g. `~/.ade/sock/desktop-bridge.sock` stable, `~/.ade-beta/sock/desktop-bridge.sock` beta), and `\\.\pipe\ade-desktop-bridge--` on Windows. The hash uses the same canonical ADE-home/channel/current-user identity as the runtime pipe. `ADE_DESKTOP_BRIDGE_SOCKET_PATH` overrides it for dev launches against a non-default ADE home. The server lives in `apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts`, wired up from `main.ts` right after `builtInBrowserService` is constructed and torn down with it on app shutdown. The runtime-side proxy is `apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts`; `createAdeRuntime` in `bootstrap.ts` assigns it to `runtime.builtInBrowserService` so the existing action registry slot resolves transparently (skipped when `runtimeProfile === "chat"`). Both sides share the same method allowlist: `getStatus, requestOriginAccess, claim, startSession, listSessions, endSession, showPanel, setBounds, navigate, createTab, switchTab, closeTab, reload, goBack, goForward, stop, observe, getTrace, click, typeText, dispatchKey, scroll, fill, clear, wait, startInspect, stopInspect, captureScreenshot, selectPoint, selectCurrent, clearSelection`. Profile diagnostics and permission administration are deliberately absent. The socket is not an authority by itself. Electron mints a rotating 256-bit bridge token in memory for each desktop launch, sends it only through the trusted local desktop client's `ade/initialize` request, and requires it on every bridge request using a timing-safe comparison. The token is held by the runtime proxy and is not placed in agent environments. Independently, `builtInBrowserActorCapabilities.ts` issues each opaque per-chat actor capability in Electron. `adeRpcServer.ts` requires the connecting CLI's token, strips caller-supplied lane/project/personal routing, and carries it through the authenticated bridge. `desktopBridgeServer.ts` validates the token against Electron's issuer-owned in-memory registry, replaces routing with the capability's bound chat/lane/project or personal scope, and forces `force: false`; revoked or cross-process-fabricated tokens fail closed. This two-token boundary prevents a raw local socket caller, an unbound CLI, or an agent that forges action arguments from inheriting the human browser session. @@ -248,12 +248,23 @@ existing behavior. Because the hook is attached in `createWindow`, local, remote-bound, and additional project windows share the same copy/paste, undo/redo, select-all, and macOS spelling-dictionary behavior. +**Platform window shell.** `src/main/windowAppearance.ts` is the source of +truth for BrowserWindow chrome. macOS keeps `hiddenInset` and traffic lights; +Windows uses a hidden title bar with a native window-controls overlay so +caption buttons, dragging, maximize, and Snap Layouts remain native. `main.ts` +sets the packaged Windows AppUserModelID to the same `com.ade.desktop` identity +used by electron-builder. Renderer startup stamps `data-platform` on the root +element for the minimal platform-specific drag/caption spacing. Capability +routing hides iOS Simulator and the native Activity Notch outside macOS; +persisted unavailable Work-sidebar state falls back to Git without disabling +the platform-neutral App Control and Browser tabs. + **Account Activity is outside the project binding.** `attentionAccountCoordinator.ts` is the desktop main-process boundary for snapshot, acknowledgment, presence, and preference calls. Signed-in reads go directly to the account push relay and therefore remain available when the window selects an old, disconnected, or differently authenticated remote -brain. A relay failure may degrade only to this Mac's local +brain. A relay failure may degrade only to this computer's local `getMachineSnapshot`; signed-out mode uses that same explicitly labeled local scope. The loaded account owner and, for machine fallback, every item's exact source revision fence later mutations. The renderer keeps this source warm in @@ -399,6 +410,7 @@ ADE uses Node's native `node:sqlite` driver (no better-sqlite3 dependency) with - **Database file**: `/.ade/ade.db`. - **WAL mode**: `openRawDatabase` sets `PRAGMA journal_mode = WAL` + `PRAGMA synchronous = NORMAL` at open. `flushNow()` forces pending WAL frames onto the main file with a `wal_checkpoint(TRUNCATE)` (used before shutdown and after a vacuum). - **CRRs**: eligible tables are marked via `SELECT crsql_as_crr('table_name')` at startup. Virtual/internal tables (`sqlite_%`, `crsql_%`) are excluded. Marking is dynamic — new tables are picked up automatically unless excluded. +- **Native extension payloads**: macOS loads `crsqlite.dylib`, Linux loads `crsqlite.so`, and Windows x64 loads the vendored `crsqlite.dll`. Windows package validation loads the installed DLL into an in-memory `node:sqlite` database, converts a table to a CRR, writes one row, and requires at least one `crsql_changes` record. A missing/unloadable extension disables device sync and is surfaced in Connections with reinstall/restart guidance instead of remaining a log-only warning. - **Sync API** (`AdeDb.sync`): `getSiteId()`, `getDbVersion()`, `exportChangesSince(version, { maxRows?, throughDbVersion?, excludeTables?, rejectOversizedVersionGroup? })`, `applyChanges(changes)`. Used by the sync transport. - **Merge semantics**: last-writer-wins per column with Lamport timestamps; each device has a site ID at `.ade/secrets/sync-site-id`. - **Engineering rule under CRR retrofit**: app-level `ON CONFLICT(...)` upserts must target PK only; secondary UNIQUE constraints do not survive CRR marking. @@ -842,7 +854,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `github/` | `githubService.ts` | GitHub REST/GraphQL access; PR CRUD; checks; reviewers. | | `history/` | `operationService.ts` | Operation audit records (one row per mutation). | | `ios/` | `iosSimulatorService.ts` | macOS-only iOS Simulator backend: tool readiness probes, simctl device + app discovery, build/install/launch with progress events (hardened with `simctl bootstatus` and `simctl install` timeouts), screenshot + ADEInspector + accessibility hit-test, Simulator.app window live-view status, idb-backed input, and single-owner chat session locking. The macOS Simulator window placement / capture state probe (`getSimulatorWindowState`, `prepareSimulatorWindowForCapture`) lives next to the IPC handlers in `ipc/registerIpc.ts` because it depends on the active `BrowserWindow`. See [features/ios-simulator/README.md](./features/ios-simulator/README.md). | -| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `runtimeEventSubscriptionRegistry.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeEventSubscriptionRegistry.ts` holds runtime-event subscriptions keyed by (sender, requestKey) with idle expiry and a single removal path (see §5.4). `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / project-open / action dispatch / sync dispatch / event stream, per-target `listActionRegistry` lookup against the remote daemon, LAN + Tailscale discovery with diagnostics) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. The explicit `ade.sync.getLocalStatus` handler is the exception: it calls machine-level `sync.getStatus` on `LocalRuntimeConnectionPool` (with only the local in-process diagnostics service as fallback) so a remote-bound Connections panel can still identify the physical Mac (its This Mac card, pairing code, and local Phone/Web device lists). Device and pairing *mutations* still follow the window binding, so the panel presents them read-only while remote-bound rather than routing them to the remote machine. Event-stream subscription init/results preserve replay-gap metadata (`gap`, `oldestCursor`, `eventEpoch`) for both local and remote bindings, and subscription bookkeeping is delegated to `runtimeEventSubscriptionRegistry.ts`; `runtimeBridge.ts` derives the request key (one helper shared by the subscribe and release paths, so a release rebuilds exactly the key subscribe registered) and registers the `ade.runtime.events.release` handler, which resolves the binding from the same descriptor shape the subscribe call used and refuses to act on an unauthorized local root. Remote project opens are generation-guarded per window/webContents before main persists the binding. It also subscribes `powerMonitor` `resume` and `unlock-screen` to `remoteConnectionService.probeSavedConnections()` so a laptop waking up cycles dead SSH sessions before the renderer pokes them. Machine-level sync fallback recognizes only the canonical unavailable-service predicates in `shared/runtimeErrors.ts`, shared with preload and renderer recovery guidance. `ipcTimeouts.ts` carries the default 30-second handler timeout plus named channel-level overrides for long direct IPC operations; it does not inspect runtime action payloads. | +| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `runtimeEventSubscriptionRegistry.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeEventSubscriptionRegistry.ts` holds runtime-event subscriptions keyed by (sender, requestKey) with idle expiry and a single removal path (see §5.4). `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / project-open / action dispatch / sync dispatch / event stream, per-target `listActionRegistry` lookup against the remote daemon, LAN + Tailscale discovery with diagnostics) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. The explicit `ade.sync.getLocalStatus` handler is the exception: it calls machine-level `sync.getStatus` on `LocalRuntimeConnectionPool` (with only the local in-process diagnostics service as fallback) so a remote-bound Connections panel can still identify the physical computer (its This computer card, pairing code, and local Phone/Web device lists). Device and pairing *mutations* still follow the window binding, so the panel presents them read-only while remote-bound rather than routing them to the remote machine. Event-stream subscription init/results preserve replay-gap metadata (`gap`, `oldestCursor`, `eventEpoch`) for both local and remote bindings, and subscription bookkeeping is delegated to `runtimeEventSubscriptionRegistry.ts`; `runtimeBridge.ts` derives the request key (one helper shared by the subscribe and release paths, so a release rebuilds exactly the key subscribe registered) and registers the `ade.runtime.events.release` handler, which resolves the binding from the same descriptor shape the subscribe call used and refuses to act on an unauthorized local root. Remote project opens are generation-guarded per window/webContents before main persists the binding. It also subscribes `powerMonitor` `resume` and `unlock-screen` to `remoteConnectionService.probeSavedConnections()` so a laptop waking up cycles dead SSH sessions before the renderer pokes them. Machine-level sync fallback recognizes only the canonical unavailable-service predicates in `shared/runtimeErrors.ts`, shared with preload and renderer recovery guidance. `ipcTimeouts.ts` carries the default 30-second handler timeout plus named channel-level overrides for long direct IPC operations; it does not inspect runtime action payloads. | | `jobs/` | `jobEngine.ts` | Event-driven background scheduler for lane refresh + conflict prediction. Coalesced, debounced. | | `keybindings/` | `keybindingsService.ts` | User keybindings read/write. | | `lanes/` | `laneService.ts`, `laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneProxyService.ts`, `portAllocationService.ts`, `autoRebaseService.ts`, `rebaseSuggestionService.ts`, `laneLaunchContext.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts` | Worktree lifecycle, env bootstrap, templates, reverse proxy, port leases, auto-rebase, suggestions, OAuth redirect, diagnostics. | @@ -1061,7 +1073,7 @@ therefore target a remote brain when the window is remote-bound. `window.ade.sync.getLocalStatus(args?: SyncGetStatusArgs): Promise` intentionally bypasses that binding through the `ade.sync.getLocalStatus` IPC channel and reads the machine-level local brain. -It is the local source the Connections panel can use for the This Mac identity, +It is the local source the Connections panel can use for the This computer identity, pairing code, and local Phone/Web device lists. `window.ade.attention.*` intentionally does not follow that binding. Electron @@ -1297,7 +1309,7 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s - **Sync authority**: a runtime on one reachable machine owns live execution side effects (agents and PTYs) for a given project. Stored in the synced `sync_cluster_state` singleton row (`brain_device_id` is the legacy internal column name). Transfer requires a clean preflight with no running turns or live PTYs. CTO history and idle chats are durable and survive handoff. - **Controllers**: other connected devices (phones always; a second desktop optionally). Controllers read synced state and send commands to the authority runtime. -- **Independent desktops**: a second Mac can run its own ADE runtime and work independently through git without joining an ADE sync session. The tracked `.ade/` scaffold/config layer makes a clone look like an ADE project immediately. +- **Independent desktops**: a second computer can run its own ADE runtime and work independently through git without joining an ADE sync session. The tracked `.ade/` scaffold/config layer makes a clone look like an ADE project immediately. ### 13.3 iOS companion sync model @@ -1508,7 +1520,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): - `runtimeBinaryPermissions.cjs` — restores exec bits on `node-pty` spawn helpers, Codex vendor binaries, Claude SDK ripgrep helpers; patches `node-pty` `unixTerminal.js` for ASAR-unpacked paths. - `after-pack-runtime-fixes.cjs` — electron-builder after-pack hook. Covers both platforms: runs the permissions pass on macOS and stages CLI wrappers + runtime shims on Windows. -- `validate-mac-artifacts.mjs` / `validate-win-artifacts.mjs` — per-platform artifact validators; confirm expected binaries, release signing state, bundled ADE CLI help, isolated ADE Code TUI help, and every required bundled ADE Agent Skill `SKILL.md`. They also fail if the bundled TUI references `__dirname` / `__filename` without ESM shims. Windows signing verification is opt-in with `--require-signed` or `ADE_REQUIRE_WIN_SIGNING=1`. +- `validate-mac-artifacts.mjs` / `validate-win-artifacts.mjs` — per-platform artifact validators; confirm expected binaries, release signing state, bundled ADE CLI help, isolated ADE Code TUI help, PTY/provider resolution, CR-SQLite mutation, updater authority, and every required bundled ADE Agent Skill `SKILL.md`. They also fail if the bundled TUI references `__dirname` / `__filename` without ESM shims. Windows signing verification is mandatory in `dist:win:signed` and opt-in for lower-level validation with `--require-signed` or `ADE_REQUIRE_WIN_SIGNING=1`. - `notarize-mac-dmg.mjs` — Apple notarization. ### 14.5 Documentation diff --git a/docs/README.md b/docs/README.md index 7a08c9144..ccaec8b88 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,8 @@ docs/ ├── logging.md # logging + PostHog ground truth ├── OPTIMIZATION_OPPORTUNITIES.md # codebase-wide optimization backlog ├── playbooks/ -│ └── ship-lane.md # autonomous PR-to-merge driver +│ ├── ship-lane.md # autonomous PR-to-merge driver +│ └── windows-signed-release.md # signed Windows publication └── features/ ├── agents/ # agent identity, tools, personas ├── ade-code/ # terminal Work chat docs; source lives in apps/ade-cli/src/tuiClient diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index cbc9923af..bc0c7dc1d 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -2,6 +2,20 @@ This worktree/branch exists to keep **ADE desktop** fully usable on **Windows** while `main` adds product features. Treat it as the integration lane for the Windows build: rebase it onto the latest `main` regularly, then run Windows-focused validation before shipping Windows installers. +## Current implementation boundary + +The Windows x64 implementation is now wired end to end in source and CI: +local runtime service, user/channel-isolated named pipes, ConPTY/provider +resume, App Control launch, Windows chrome/capability gating, packaged +CR-SQLite proof, NSIS packaging, updater authority, and fail-closed signed +release gates. The pull-request build is intentionally an **unsigned internal +preview**. Public availability remains disabled until the external clean-VM +proof gates below pass. + +Windows 10/11 x64 is the supported target. Windows ARM64, Windows as an +SSH-bootstrap remote brain target, native Windows OS computer use, and iOS +Simulator remain separate follow-ups. + ## Keep this branch current with `main` From this repo: @@ -58,9 +72,10 @@ Recent `main` work that is **not** inherently macOS-only but can surface path/sh - **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, `release-core.yml` fails the Windows jobs unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match `WINDOWS_SIGNING_EXPECTED_SUBJECT` or `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`; the installer and packaged app must also share one certificate. That test job cannot block macOS publication while public Windows publication is disabled. SmartScreen reputation remains a release-engineering concern, not only app code. - **Docs in `AGENTS.md`** still emphasize macOS Codex/Computer Use; Windows developers should use this file + `docs/ARCHITECTURE.md` for WSL/VM dev notes if applicable. -## Engineering backlog (complete the “parity” bar) +## External proof gates before public availability -Do these to move from “runs on Windows” to “first-class for Windows users”: +The implementation and automated contract tests do not replace installed-host +verification. Complete these before enabling the public website/release flags: 1. **Clean standard-user hosts** — install/uninstall/reinstall on Windows 10 22H2 and Windows 11 x64 with no global Node; verify first launch, Start Menu @@ -102,7 +117,9 @@ and publication procedure before this stack can be considered ready. ```bash npm --prefix apps/desktop run typecheck -npm --prefix apps/desktop run test -- --run apps/desktop/src/renderer/lib/pathUtils.test.ts apps/desktop/src/main/services/shared/processExecution.test.ts +npm --prefix apps/desktop run test:win:release-contract +npm --prefix apps/desktop run test -- --run src/main/windowAppearance.test.ts src/main/services/transcription/microphoneAccess.test.ts src/main/services/appControl/appControlService.test.ts src/main/services/pty/ptyService.test.ts src/main/services/updates/autoUpdateService.test.ts +npm --prefix apps/ade-cli run test -- --run src/serviceManager/common.test.ts src/services/projects/machineLayout.test.ts src/services/sync/syncHostSingleton.test.ts npm --prefix apps/desktop run build ``` diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index e09990c71..4ab318c5e 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -24,7 +24,7 @@ subagents, computer use). The pane derives all visible state from the | `ProviderFailureRecoveryCard.tsx` | Friendly recovery surface for terminal provider capacity and usage-limit failures. Shows human-readable error identity and guidance, then offers **Retry turn** and **Choose model** only after the failed turn has released the composer. | | `chatTurnState.ts` | Pure turn-state helpers shared by live and hydration paths. Terminal transcript evidence beats a stale active session summary, and failed-turn retry resolves the associated non-steer user message even when the optimistic row has no provider turn id. | | `ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Chat Actions tab shell plus Codex Sources view. The source derivation deduplicates files, web queries/results, MCP apps/tools, and external resource URLs from transcript events; safe web rows open in ADE's browser. | -| `VoiceDictationButton.tsx`, `apps/desktop/src/renderer/services/globalVoiceRecorder.ts`, `apps/desktop/src/renderer/components/voice/*` | Desktop dictation UI and recorder. The module-level recorder owns mic capture across navigation, writes live state to the root app store, transcribes via `window.ade.transcription`, inserts cleaned text into the registered composer, and always copies the cleaned transcript to the clipboard. The header indicator and composer pill render the same recording state. | +| `VoiceDictationButton.tsx`, `microphonePermissionGuidance.ts`, `apps/desktop/src/renderer/services/globalVoiceRecorder.ts`, `apps/desktop/src/renderer/components/voice/*`, `apps/desktop/src/main/services/transcription/microphoneAccess.ts` | Desktop dictation UI, platform permission boundary, and recorder. The module-level recorder owns mic capture across navigation, writes live state to the root app store, transcribes via `window.ade.transcription`, inserts cleaned text into the registered composer, and always copies the cleaned transcript to the clipboard. Main asks Electron for the platform microphone status; Windows denied/restricted states return explicit Privacy & security > Microphone guidance, while unknown/not-determined states are left to Chromium's origin permission flow. The header indicator and composer pill render the same recording state. | | `apps/desktop/src/main/services/transcription/*` | Electron main-process transcription service. Writes captured 16 kHz mono PCM to WAV, runs bundled whisper.cpp `base.en`, parses the JSON sidecar, and applies deterministic glossary cleanup. | | `apps/desktop/resources/voice/voice-glossary.json`, `apps/desktop/resources/whisper/README.md` | Shared dictation glossary and release notes for materialized whisper resources. The large model and binary are generated by `materialize-whisper-resources.mjs` and ignored by git. | | `apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx` | Ambient `settled` / `snoozed` chips for the chat surface header, mounted by `WorkSurfaceHeader` through its `lifecycleSessionId` prop (`AgentChatPane` passes the selected session id). `useSessionLifecycleSnapshot(sessionId)` reads the session row out of the per-project session cache the Work tab already mirrors into the app store, so there is no extra IPC; the phase/snooze derivations are the shared `sessionCanonicalUiState` + `isSessionSnoozed` / `snoozeWakeLabel` helpers the Work sidebar uses. Chip menus call `wakeSessionNow` / `setSessionSettleOverride` from `renderer/components/terminals/sessionLifecycleActions.ts`, falling back to `window.ade.sessions.unsettle` for a declared settle. | @@ -432,7 +432,12 @@ that could not work without it. transcript at the registered composer's cursor, and copies the same text to the clipboard as a recovery path. The composer pill and top bar pill both observe the root-store dictation slice, so their timer - and waveform stay in sync. + and waveform stay in sync. macOS permission requests use + `systemPreferences.askForMediaAccess`; Windows reads Electron's media access + status and blocks only explicit `denied` / `restricted` results. The error + card directs Windows users to **Settings > Privacy & security > Microphone** + and names both device access and app access instead of showing macOS System + Settings copy. - **Fast mode.** Toggles the legacy-named `codexFastMode` bit for the selected session. Fast mode is a property of a *model*, so the toggle lives on the model row inside the shared `ModelPicker` rather than as a diff --git a/docs/features/computer-use/README.md b/docs/features/computer-use/README.md index 2e0dc7e0a..81e5b4ad2 100644 --- a/docs/features/computer-use/README.md +++ b/docs/features/computer-use/README.md @@ -7,6 +7,14 @@ ADE has two intentionally separate computer-use responsibilities: Execution does not imply proof. ADE never passively promotes every Computer Use tool result into a durable artifact. +On Windows, the macOS Codex Computer Use client and native OS capture/control +remain unavailable and report `blocked_by_capability`. This does not disable +the platform-neutral surfaces: App Control can launch and drive developer-owned +Electron apps through CDP, and proof-file ingestion continues to accept +intentional screenshots, videos, traces, and logs. The renderer hides the +macOS-native Attention Notch and iOS Simulator controls rather than presenting +actions that cannot succeed. + The previous proof control-plane model — policy modes (`off`/`auto`/`enabled`), readiness gates, per-phase evidence requirements, a passive proof observer — is gone. The proof side is now a thin broker backed by canonical artifact and owner-link tables; direct Codex execution is the provider-native MCP path described below. See [`../proof.md`](../proof.md) for the user-facing CLI surface (`ade proof capture` / `attach` / `list`) and the chat collection UI contract. diff --git a/docs/features/computer-use/app-control.md b/docs/features/computer-use/app-control.md index 95a57e82a..ea7caa45e 100644 --- a/docs/features/computer-use/app-control.md +++ b/docs/features/computer-use/app-control.md @@ -18,7 +18,13 @@ App Control runs on the runtime that owns the project. The launch terminal, CDP - input: `click`, `typeText`, `scroll`, `dispatchKey` - launch terminal passthrough: `readTerminal`, `writeTerminal`, `signalTerminal` - screencast frames stream out via the `onEvent` channel (`type: "frame"`) -- `appControlLaunchCommand.ts` — pure shell-command helpers for detecting direct Electron launches, detecting package-manager script launches, rewriting package scripts to inject App Control debug flags, and preserving the `{ADE_APP_CONTROL_DEBUG_FLAGS}` opt-in path. +- `appControlLaunchCommand.ts` — launch parsing and rewrite helpers for direct + Electron and package-manager script commands. On Windows, resolvable + `electron` / `npx electron` and package-script launches become structured + command/argv/env descriptors; shell fallbacks emit explicit PowerShell or + cmd syntax. POSIX retains the existing shell rewrite semantics. The literal + `{ADE_APP_CONTROL_DEBUG_FLAGS}` opt-in path remains available on every + platform. - `appControlService.test.ts` — service tests. - `appControlLaunchCommand.test.ts` — launch-command rewrite coverage. @@ -108,10 +114,16 @@ The agent guidance built by `apps/desktop/src/shared/adeCliGuidance.ts` tells ag 1. **Argument resolution.** `appKind` defaults to `"electron"`. `cwd` is normalized against the resolved `projectRoot` and rejected if it escapes the lane worktree (`ensureCwdInsideRoot`). `cdpPort` is allocated via `findFreePort()` when not supplied. `ADE_APP_CONTROL_CDP_PORT` and `ADE_APP_CONTROL_DEBUG_FLAGS` are computed and either: - substituted into a literal `{ADE_APP_CONTROL_DEBUG_FLAGS}` placeholder in the command, or - - injected when the command looks like a package script (`npm`/`pnpm`/`yarn`/`bun run dev`) by rewriting it to `--inspect`/`--remote-debugging-port` flags, or + - injected when the command looks like a package script (`npm`/`pnpm`/`yarn`/`bun run dev`) by resolving the package script and adding `--inspect`/`--remote-debugging-port` flags, or - appended directly when the command looks like a `npx electron`/`electron` invocation, or - exported via the spawned shell's environment for any other custom launcher (custom launchers are expected to forward one of those env vars to `--remote-debugging-port`). -2. **Visible chat terminal.** Instead of spawning a hidden child process, the service runs the resolved command through the chat-owned PTY (`ptyService.create(...)` with `chatSessionId`). The user sees the stdout/stderr in the chat terminal drawer, and the App Control session records the resulting `terminalSessionId` + `terminalPtyId`. + On Windows, recognized direct/package commands are passed to `ptyService` + as a structured executable, argv, environment, and cwd. This avoids POSIX + `PATH=...:$PATH` syntax and preserves spaces, quotes, `%`, `$`, and `&`. + A command that genuinely needs a shell falls back to platform-specific + PowerShell or cmd quoting. Structured parsing is deliberately Windows-only; + macOS/Linux custom shell semantics are not reinterpreted. +2. **Visible chat terminal.** Instead of spawning a hidden child process, the service runs the resolved command through the chat-owned PTY (`ptyService.create(...)` with `chatSessionId`). The user sees the stdout/stderr in the chat terminal drawer, and the App Control session records the resulting `terminalSessionId` + `terminalPtyId`. App Control's CDP provider is supported on Windows; OS-level computer use remains a separate macOS-only capability. 3. **CDP discovery.** `listCdpTargets(port)` polls `http://127.0.0.1:/json` every 500 ms. A health-check timer keeps polling at 2 s once a target is selected. `pickCdpTarget` prefers `page` > `webview` > anything with a non-`devtools://` URL. 4. **Attach.** `CdpClient.connect(webSocketDebuggerUrl)` opens the long-lived WebSocket. The session transitions `starting` → `running` → `connected` and `cdpEndpoint` / `cdpTargetId` are filled in. `Page.startScreencast` is enabled lazily so the renderer panel can paint frames. 5. **Health.** If the WebSocket drops, the session moves back to `running` (terminal still alive) or `failed` (terminal exited). `lastError` carries the last CDP failure for the renderer to display. diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index baf5fba82..560224d81 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -682,7 +682,15 @@ banner): version clears the cache. On packaged launches with a recently installed update, the desktop refreshes the per-user runtime service so `ade serve` re-execs the updated bundled CLI and clients - do not fall back to an isolated build-mismatch runtime. + do not fall back to an isolated build-mismatch runtime. On Windows, + electron-builder generates the installed `app-update.yml` from the build's + GitHub publish authority; package smoke requires it to match + `ADE_RELEASE_REPOSITORY`. The source default remains upstream + `arul28/ADE`; fork CI overrides it with the repository performing the build. + The NSIS handoff uses the non-macOS 60-second quit deadline. Signed/public + Windows release assets remain behind the independent repository-variable + gates described in + [desktop-auto-update.md](./desktop-auto-update.md#windows-update-path). - `apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx` — the primary update control: a small badge in the app shell top bar. Shows "Checking for updates" / "Downloading vX.Y.Z (NN%)" / "Install update @@ -780,7 +788,7 @@ the General settings tab via `AdeCliSection`: 3. Register the runtime as a per-user login service so it survives reboots. `installServiceBestEffort()` runs `ade serve --install-service` once per session; the implementation lives in - `apps/ade-cli/src/serviceManager/` (launchd / systemd / schtasks). + `apps/ade-cli/src/serviceManager/` (launchd / systemd / Windows per-user startup). The result is exposed as `LocalRuntimeStatus.serviceInstall` and `serviceHealth` (`unsupported | not_installed | installed | running | error | unknown`). diff --git a/docs/features/onboarding-and-settings/desktop-auto-update.md b/docs/features/onboarding-and-settings/desktop-auto-update.md index 96d15ee0f..eb5092959 100644 --- a/docs/features/onboarding-and-settings/desktop-auto-update.md +++ b/docs/features/onboarding-and-settings/desktop-auto-update.md @@ -28,6 +28,35 @@ The practical volume checks are therefore: - Before staging/install: `process.execPath`, which resolves to the installed application bundle's volume on macOS. +## Windows update path + +Windows x64 uses electron-builder's per-user NSIS target and +`electron-updater`'s `latest.yml` + blockmap contract: + +1. Electron-builder generates the installed `resources/app-update.yml` from + the package's GitHub publish configuration. ADE does not copy the + source-tree YAML into the package. `ADE_RELEASE_REPOSITORY=owner/repo` + lets CI bind a fork package to the repository that produced it while the + source default remains the upstream `arul28/ADE`. +2. `checkForUpdates()` reads `latest.yml`; ADE keeps `autoDownload` disabled + until it has run the same cache-volume capacity preflight used on macOS. +3. `downloadUpdate()` writes the NSIS installer and blockmap into the updater + cache. `quitAndInstall()` hands off to the external NSIS updater, so Windows + uses the 60-second hard quit bound and has no in-process Squirrel staging + signal. +4. The packaged-artifact smoke test requires the generated update authority + to match `ADE_RELEASE_REPOSITORY`, preventing a fork build from silently + checking a different repository. + +Release generation and public availability are separate gates. +`ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` enables a fail-closed Authenticode build; +the installer and packaged `ADE.exe` must share the pinned publisher identity +and carry a trusted RFC3161 timestamp. `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` +adds the installer, blockmap, and `latest.yml` to the draft release. Keep the +public gate disabled until the signed installer passes the clean standard-user +Windows checks. Validate version-to-version automatic updating after two signed +Windows releases exist. + ## Required-space estimate Release metadata reports compressed archive size, not expanded application diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 04ab1c0fe..b4090a8cd 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -90,7 +90,7 @@ relay payload E2E encryption is planned security work. See the trust boundary in `pubkey`, which same-account clients verify before a sealed `ade-adopt-v1` adoption over a direct route (see the [Sync](../sync-and-multi-device/README.md) security model). The published machine name is channel-suffixed (` · - Beta` / ` · Alpha`, stable left bare) so two channels on one Mac are + Beta` / ` · Alpha`, stable left bare) so two channels on one computer are distinguishable rows. - `apps/ade-cli/src/services/sync/syncTunnelClientService.ts` and `apps/ade-cli/src/bootstrap.ts` — the relay side of a paired route. The tunnel @@ -132,7 +132,7 @@ relay payload E2E encryption is planned security work. See the trust boundary in the brain's `pid`, its bound `syncPort`, the account-directory `publishHealth` slice (state + `failingSinceMs` + last-leg durations), and the one-shot `lastWedge` recovered by the event-loop watchdog. The Machines panel renders - publish health as a This-Mac indicator (`remoteMachineModel.describePublishHealth`, + publish health as a This-computer indicator (`remoteMachineModel.describePublishHealth`, which reads inactive states as "none" and only alarms a real failure after it has persisted ~2 minutes), and the app shell reads `lastWedge` for the `BrainRecoveryNotice` banner. @@ -155,7 +155,7 @@ relay payload E2E encryption is planned security work. See the trust boundary in connected / available / unavailable sections, Pair and SSH entry paths, share-this-machine and connection-doctor cards, saved/discovered machine rows, route and latency status, SSH host-key trust, structured connection - errors, project picker, and the This-Mac + errors, project picker, and the This-computer route-publish health indicator. `remoteMachineModel.ts` (`describePublishHealth`) is the pure classifier for that indicator: the publishing `published` state reads healthy, the non-publishing states @@ -461,12 +461,12 @@ run a local repair against data owned by the remote machine. See ## Connect flow -1. Open **Connections > Machines**. When signed in, ADE loads the other Macs on +1. Open **Connections > Machines**. When signed in, ADE loads the other computers on the same account. It also combines Bonjour and Tailscale discovery, removes this machine's own Bonjour advertisement, and merges routes that identify the same machine. Discovered paired-capable ADE desktops appear in Available; offline or unsupported machines remain visible in Unavailable. -2. Select a same-account Mac for the primary PIN-less flow. ADE dials the +2. Select a same-account computer for the primary PIN-less flow. ADE dials the directory-verified Relay first; when the target publishes an ed25519 identity key in its directory row (`pubkey`), adoption can also fall back to Tailscale and LAN routes using the sealed `ade-adopt-v1` handshake — @@ -493,9 +493,10 @@ run a local repair against data owned by the remote machine. See traffic, but never the sealed credentials. This matches the pre-existing direct-route trust boundary; relay routes remain trusted-operator plaintext-readable as documented above. - Without an account, choose **Find nearby Macs**, select a discovered LAN or - Tailscale machine, and enter the six-digit PIN shown on that Mac's **This - Mac** Connections card. There is no desktop pairing-link paste/scan or + Without an account, choose **Find nearby computers**, select a discovered + LAN or Tailscale machine, and enter the six-digit PIN shown on that + computer's **This computer** Connections card. There is no desktop + pairing-link paste/scan or manual address + PIN path. A discovered machine with an existing pairing is upgraded to a paired target automatically. 3. Connect. ADE dials paired routes in LAN → tailnet → relay order, preferring a @@ -599,6 +600,12 @@ Version skew and capability skew no longer fail the connect outright. The bootst Desktop distributable builds require `apps/desktop/resources/runtime/` to contain every supported `ade-` binary and matching `.native.tar.gz` archive, plus the packaged ADE CLI resources that include `ptyHostWorker.cjs` for remote terminal hosting. The supported targets are `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`. +The Windows x64 desktop package carries that same complete Darwin/Linux +sidecar set, so a Windows client can bootstrap supported SSH targets. It also +contains its own bundled Windows CLI/native resources for the local brain. +`win32-x64` is not currently an SSH-bootstrap target and is intentionally not +part of the remote sidecar list. + Desktop distributable builds also package `apps/desktop/resources/agent-skills/`. Remote bootstrap copies that directory into the selected remote ADE home as `agent-skills/`; the CLI then re-seeds ADE-managed skills into runtime-native @@ -667,9 +674,9 @@ traffic always uses the paired sync WebSocket advertised on the LAN, through a Tailscale tailnet, or through the relay. Install Tailscale on the phone and the ADE machine for a direct route when they are not on the same local network. -On desktop, the **This Mac** card and **Connections > Phone**/**Web** tabs are -runtime controls that always describe the **physical Mac the user is sitting -at**, even while the window is bound to a remote project. Most +On desktop, the **This computer** card and **Connections > Phone**/**Web** tabs +are runtime controls that always describe the **physical computer running this +ADE desktop**, even while the window is bound to a remote project. Most `window.ade.sync.*` calls follow the active binding and would report the remote machine, so the Connections panel reads its identity, pairing code, and local device lists through `window.ade.sync.getLocalStatus(...)`, which deliberately @@ -680,8 +687,8 @@ window is remote-bound and to name the machine it is working on. Because the pairing and device *mutations* (`setPin`, `generatePin`, `clearPin`, `forgetDevice`, name edits) continue to route through the binding, the panel presents them **read-only while remote-bound** and labels the connected-device -list with the local Mac's name so it cannot be mistaken for the bound machine's. -The pairing PIN manager lives on the This Mac card. The legacy in-process +list with the local computer's name so it cannot be mistaken for the bound machine's. +The pairing PIN manager lives on the This computer card. The legacy in-process desktop sync host is disabled by default and can be re-enabled only for diagnostics with `ADE_ENABLE_DESKTOP_SYNC_HOST=1`. @@ -719,8 +726,8 @@ identity (and its `siteId`) before it sends the pairing request, because the saved record up by two things that both identify a *host*: the caller-supplied `hostDeviceId` (a QR/link payload and account-directory adoption both carry it), then the relay machine key parsed out of a `/connect/` endpoint. A bare LAN -address is not a host identity — DHCP handing `192.168.1.240` to a different Mac -would hand that Mac the identity this desktop uses with the first one — so +address is not a host identity — DHCP handing `192.168.1.240` to a different computer +would hand that computer the identity this desktop uses with the first one — so matching on a saved endpoint is deliberately not an option, and a LAN pairing with no `hostDeviceId` mints a fresh identity. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 586180daa..f21aa9de0 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -1,7 +1,7 @@ # Sync and Multi-Device ADE syncs live runtime state across an ADE machine runtime and any connected -controllers (other Macs, iPhones) using **cr-sqlite** as a CRDT-backed +controllers (other computers, iPhones) using **cr-sqlite** as a CRDT-backed replication layer over a **WebSocket** transport. The design is local-first: eligible routes are preferred in **LAN → Tailscale → Relay** order for desktop-to-runtime, ADE Code, and iOS connections. The account-gated cloud @@ -68,6 +68,14 @@ diagnostics. It is **disabled by default** and only activates when sessions both leave it off; everything below describes the runtime-hosted path unless explicitly noted. +Windows x64 can own the same machine sync authority as macOS/Linux. It uses a +per-user/channel named pipe for local RPC and the packaged +`vendor/crsqlite/win32-x64/crsqlite.dll` for CRR replication. The typed +`crdtSyncAvailable` status prevents pairing when the extension is unavailable; +Connections surfaces the failure with reinstall/restart guidance. Native +macOS computer use and iOS Simulator are separate capabilities and do not gate +Windows phone pairing, App Control, browser control, or proof ingestion. + ### The machine-wide sync host lease Hosting phone sync is exclusive per machine, and that exclusivity is a real @@ -360,7 +368,7 @@ Runtime support files outside `services/sync/`: machine `name` is suffixed by package channel (`publishedMachineName`): a Beta build advertises ` · Beta` and an Alpha build ` · Alpha`, while a stable build (or an already-suffixed name) is left untouched, so the same - physical Mac running two channels shows as two distinguishable directory rows. + physical computer running two channels shows as two distinguishable directory rows. A LAN endpoint is only emitted for an address candidate whose `kind` is `lan`; because `syncPairingConnectInfo.buildAddressCandidates` now classifies the saved `lastHost` as `lan`/`tailscale` when it matches the current address set @@ -399,7 +407,7 @@ Runtime support files outside `services/sync/`: publication re-reads the active sync snapshot and token so a brain started before sign-in still recovers. The last typed publisher outcome is exposed as `routeHealth.accountDirectory` in - `sync.getStatus`, `ade sync status`, and the desktop This Mac card, including + `sync.getStatus`, `ade sync status`, and the desktop This computer card, including the selected directory origin, HTTP status, bounded classified HTTP reason, timestamps, and reachable-route count. A non-success response contributes `lastHttpReason` and the same reason in `skipReason`; the parser consumes at @@ -507,10 +515,10 @@ Desktop connection UI: Account page so signed-out users can return to the exact surface they left. - `apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx` — Connections uses the focused `"phone"` and `"web"` variants beneath a - shared **This Mac** card. The card owns the pairing-PIN manager and the + shared **This computer** card. The card owns the pairing-PIN manager and the internal phone QR; the Phone tab explains sign-in, QR + PIN, and Nearby + PIN, while Web is account-sign-in only. When a configured PIN is available only - as its at-rest PBKDF2 hash after a runtime restart, the This Mac card can + as its at-rest PBKDF2 hash after a runtime restart, the This computer card can generate and set a new six-digit PIN instead of leaving copy disabled. Initial-load failures show a short recovery action while keeping the raw message under **Technical details**: missing project registration asks the @@ -519,12 +527,12 @@ Desktop connection UI: restart. The local-brain-only `window.ade.sync.getLocalStatus(...)` accessor is available for the card to consume so a window bound to another machine can still show the physical - Mac's identity, pairing code, and Phone/Web device lists. + computer's identity, pairing code, and Phone/Web device lists. - `apps/desktop/src/renderer/components/settings/useSyncConnections.ts` — the hook that keeps the Connections panel local-vs-remote aware. It fetches the binding-following `sync.getStatus` **and** the machine-level - `sync.getLocalStatus` on every refresh; the This Mac card always renders the - `getLocalStatus` snapshot, so it names the physical Mac even in a remote-bound + `sync.getLocalStatus` on every refresh; the This computer card always renders the + `getLocalStatus` snapshot, so it names the physical computer even in a remote-bound window, and never substitutes a routed (remote) snapshot when the local one is unavailable. It derives `isRemoteBound` by comparing the two snapshots' `localDevice.deviceId`, exposes the bound machine's display name for labeling, @@ -532,8 +540,8 @@ Desktop connection UI: (`setPin`, `generatePin`, `clearPin`, `forgetDevice`, name edits) still follow the window binding, the panel renders the pairing code and device controls read-only while remote-bound and labels the connected device list with the - local Mac's name so it can't be mistaken for the bound machine's. When - remote-bound it also scopes the shown devices to this Mac's live + local computer's name so it can't be mistaken for the bound machine's. When + remote-bound it also scopes the shown devices to this computer's live `connectedPeers` (via `peerToRuntimeDeviceState`) instead of the routed `listDevices()` result, which would describe the remote machine; offline-paired rows are unavailable in that mode until a local-scoped device IPC exists. @@ -548,7 +556,7 @@ Desktop connection UI: `deviceType === "browser"`. - `apps/desktop/src/renderer/components/settings/SyncDevicesSection.test.tsx` and `apps/desktop/src/renderer/components/app/TopBar.test.tsx` — focused - Phone/Web variants, This Mac PIN management, internal phone-QR interactions, + Phone/Web variants, This computer PIN management, internal phone-QR interactions, account-only web guidance, web-peer chip, and sheet dismissal coverage. Cross-machine Work union: @@ -1016,7 +1024,7 @@ Canonical files (`apps/ade-cli/src/services/sync/`): - `syncPinStore.ts` — on-disk storage for the user-set 6-digit pairing PIN at `~/.ade/secrets/sync-pin.json`, chmodded `0600`. The runtime never rotates the PIN; the operator sets or clears it from - the **This Mac** card in the Connections panel. + the **This computer** card in the Connections panel. - `resolveTailscaleCliPath.ts` — Tailscale CLI discovery used for the tailnet `tailscale serve` publication path. - `syncDpop.ts` — device-bound pairing (DPoP) helpers: the canonical @@ -1342,7 +1350,7 @@ exception. Preload invokes `ade.sync.getLocalStatus` directly; main dispatches `sync.getStatus` through the machine-level `LocalRuntimeConnectionPool`, never the active window's remote project binding, with only the local in-process diagnostics service as its unavailable-runtime fallback. This is the path the -Connections This Mac projection and local pairing/device controls should use. +Connections This computer projection and local pairing/device controls should use. During project transitions, mutating sync methods (`sync.setPin`, `sync.clearPin`, `sync.connectToBrain`, lane-presence updates, model-picker @@ -1686,7 +1694,7 @@ successful adoption logs `sync_host.account_legacy_pairing_upgraded`. it (or after legacy migration), so after a restart the host can verify pairings with the existing digits if the user still knows them. It cannot display or copy those digits until the user generates or sets a - new PIN. The **This Mac** card in Connections exposes the + new PIN. The **This computer** card in Connections exposes the generate-new-PIN recovery path; the phone enters the same digits shown there after scanning the QR or choosing the machine from Nearby. ADE account sign-in is the primary PIN-less phone path through the directory and Relay. @@ -2120,7 +2128,7 @@ feature is merged or because a deliberately isolated-port host is running. this release retain their saved local/direct reconnect path. Direct pairing uses a **user-set 6-digit PIN** stored as a PBKDF2 hash in `~/.ade/secrets/sync-pin.json` on the runtime machine. The runtime never - auto-rotates or TTLs the PIN; the user manages it from the **This Mac** card + auto-rotates or TTLs the PIN; the user manages it from the **This computer** card in Connections and clears it when they want to stop accepting new pairings. Plaintext is process-local and intentionally unrecoverable after restart, so Connections @@ -2316,7 +2324,7 @@ feature is merged or because a deliberately isolated-port host is running. - **The pairing PIN is user-managed, not ADE-managed.** There is no expiry and no rotation. A machine that leaves the PIN set is perpetually pairable by anyone on the network who knows the digits - (subject to the per-IP rate limiter). Clearing the PIN from the **This Mac** + (subject to the per-IP rate limiter). Clearing the PIN from the **This computer** card in Connections is how you stop accepting new direct pairings; already-paired devices keep their per-device secret and remain connected. Because diff --git a/docs/features/sync-and-multi-device/crdt-model.md b/docs/features/sync-and-multi-device/crdt-model.md index c9ce740c1..9a1d86b3c 100644 --- a/docs/features/sync-and-multi-device/crdt-model.md +++ b/docs/features/sync-and-multi-device/crdt-model.md @@ -56,8 +56,9 @@ ade-cli modules — there is no second implementation to keep in sync. ## Desktop / daemon: native loadable extension Both the Electron main process and the `ade serve` daemon open SQLite -through `node:sqlite` and load a vendored `crsqlite.dylib` (macOS) / -`.so` (linux) as a loadable extension. A fresh connection runs +through `node:sqlite` and load a vendored `crsqlite.dylib` (macOS), +`crsqlite.so` (Linux), or `crsqlite.dll` (Windows x64) as a loadable +extension. A fresh connection runs `SELECT load_extension(...)` once, then `AdeDb` marks every eligible non-virtual table as a CRR at startup: @@ -105,6 +106,21 @@ so an always-on brain dispatching high webhook volume can't grow the table insid the 7-day retention window to the point where its rebuild fails mid-copy (see [ARCHITECTURE §3.1](../../ARCHITECTURE.md)). +### Windows package proof and failure surface + +`validate-win-artifacts.mjs` does more than check that `crsqlite.dll` exists. +Its installed-package smoke launches Electron as Node, loads the unpacked DLL +into an in-memory `node:sqlite` database, converts a probe table with +`crsql_as_crr`, inserts a row, and requires at least one matching +`crsql_changes` record. A Windows installer therefore cannot pass CI with a +wrong-architecture, misplaced, or unloadable extension. + +At runtime, `SyncRoleSnapshot.crdtSyncAvailable` carries the typed capability +to the Connections panel. If the Windows runtime still cannot load the +extension, phone pairing/sync is disabled and the **This computer** card shows +reinstall-and-restart guidance. Older runtimes that omit the typed field remain +compatible through the existing bounded status-text fallback. + Sync-managed tables support later `ALTER TABLE ... ADD COLUMN` through automatic `crsql_begin_alter` / `crsql_commit_alter` wrapping in the adapter. @@ -433,7 +449,7 @@ After apply, ADE runs post-hooks: | Piece | Status | |---|---| -| Desktop / daemon extension loading + CRR marking | Implemented | +| Desktop / daemon extension loading + CRR marking | Implemented on macOS, Linux, and Windows x64; Windows package smoke proves a real CRR change | | iOS pure-SQL emulation | Implemented, wire-compatible | | Dynamic CRR discovery | Implemented | | `ALTER TABLE ADD COLUMN` support | Implemented (wrapped) | diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 5a618a969..34a1f5342 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -974,9 +974,17 @@ Renderer surfaces: The legacy `buildTrackedCliStartupCommand` and `defaultTrackedCliStartupCommand` are now thin wrappers over `buildTrackedCliLaunchCommand` for - callers that only need the shell string. `buildTrackedCliResumeCommand` - rebuilds a resume command line from `TerminalResumeMetadata` for any - provider; `parseTrackedCliResumeCommand` + callers that only need the shell string. + `buildTrackedCliResumeLaunchCommand` rebuilds a structured + `{ command, args, env?, startupCommand }` descriptor from + `TerminalResumeMetadata` for any provider. Windows continuation paths + consume that descriptor directly, so OpenCode permission policy stays in + the process environment and Claude/Codex/Cursor argv never passes through + PowerShell quoting. Droid is the intentional shell launch: Windows uses a + no-profile PowerShell descriptor that writes its temporary settings JSON as + BOM-free UTF-8 and cleans it up after the provider exits. + `buildTrackedCliResumeCommand` remains the persisted/display POSIX string + compatibility wrapper; `parseTrackedCliResumeCommand` (`apps/desktop/src/main/utils/terminalSessionSignals.ts`) is the inverse it relies on for round-tripping. It also owns the shell-command-line primitives `ptyService` uses to place Claude's `--plugin-dir` flag on the real @@ -1546,7 +1554,7 @@ PTY: |---|---| | `ade.pty.create` | create or reattach; returns `{ ptyId, sessionId, pid }`. Accepts an optional `chatSessionId` to mark the terminal as attached to that owner session. | | `ade.pty.resumeSession` | prompt-free tracked CLI relaunch. Args: `{ sessionId, cols?, rows?, model?, reasoningEffort?, permissionMode? }`. Reuses a live PTY when attached; otherwise validates the row, backfills a missing resume target when possible, rebuilds the provider resume command, and spawns a continuation PTY in the same `terminal_sessions` row without writing a prompt. Returns `PtyResumeSessionResult` (`{ ptyId, sessionId, pid, session, resumed, reusedExistingRuntime }`). | -| `ade.pty.sendToSession` | send-or-continue. Args: `{ sessionId, text, cols?, rows?, model?, reasoningEffort?, permissionMode? }`. Submits text into the live PTY when one is attached; otherwise validates that the row is a tracked agent CLI session, backfills a missing resume target when possible, rebuilds the resume command via `buildTrackedCliResumeCommand` (honouring runtime overrides), spawns the continuation PTY in the same `terminal_sessions` row, and includes the user's text in the launch command when resume metadata is available. Later sends that land after a resume flight has started are serialized through the agent CLI input protocol: line clear, bracketed paste envelope, chunked 64-byte writes with 5 ms inter-chunk delay, then carriage return with a provider-specific submit delay. Returns `PtySendToSessionResult` (`{ ptyId, sessionId, pid, session, resumed, reusedExistingRuntime }`). | +| `ade.pty.sendToSession` | send-or-continue. Args: `{ sessionId, text, cols?, rows?, model?, reasoningEffort?, permissionMode? }`. Submits text into the live PTY when one is attached; otherwise validates that the row is a tracked agent CLI session, backfills a missing resume target when possible, rebuilds the provider launch via `buildTrackedCliResumeLaunchCommand` (honouring runtime overrides), spawns the continuation PTY in the same `terminal_sessions` row, and includes the user's text in the launch when resume metadata is available. Windows consumes the returned command/argv/env directly; POSIX retains the existing shell-string compatibility path. Later sends that land after a resume flight has started are serialized through the agent CLI input protocol: line clear, bracketed paste envelope, chunked 64-byte writes with 5 ms inter-chunk delay, then carriage return with a provider-specific submit delay. Returns `PtySendToSessionResult` (`{ ptyId, sessionId, pid, session, resumed, reusedExistingRuntime }`). | | `ade.pty.write` | write bytes to PTY | | `ade.pty.resize` | cols/rows resize | | `ade.pty.dispose` | close PTY; optional `sessionId` used for logging | diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md index 3594589ca..1fb88c712 100644 --- a/docs/features/terminals-and-sessions/pty-and-sessions.md +++ b/docs/features/terminals-and-sessions/pty-and-sessions.md @@ -216,6 +216,14 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: applied per candidate so an `args.env` from the caller is overlaid first, then the clean-shell `env` block, before `ptyLib.spawn`. + - Windows tracked-provider continuation prefers + `buildTrackedCliResumeLaunchCommand`'s structured + `command` / `args` / `env` fields. The persisted + `resume_command` remains a display/legacy compatibility string; it is not + typed into PowerShell when structured metadata is available. OpenCode + replay uses the same rule, and Droid uses an explicit no-profile + PowerShell descriptor because its temporary settings-file lifecycle + genuinely requires shell statements. 10. If the spawn ended up in a shell (no direct launch, or direct launch fell back), type `args.startupCommand` into the PTY so the shell executes the CLI. Direct launches that succeeded skip this — @@ -543,14 +551,16 @@ write paths into one call: resume-target backfill used by `ensureResumeTargets`; Codex can scan rollout storage during this resume-launch path before ADE reports a missing target. -3. Rebuild the resume command via - `buildTrackedCliResumeCommand(metadata, overrides)` — runtime +3. Rebuild the resume launch via + `buildTrackedCliResumeLaunchCommand(metadata, overrides)` — runtime `model` / `reasoningEffort` / `permissionMode` overrides flow into - the command line so the continuation honours the user's current + the invocation so the continuation honours the user's current model picker. For the first ended-session continuation with structured `resumeMetadata`, `text` is also passed as the provider prompt argument, except for Cursor where ADE waits for the interactive - prompt and writes the text through PTY input. + prompt and writes the text through PTY input. Windows consumes direct + argv/env (or Droid's explicit PowerShell descriptor); POSIX retains the + established shell-string compatibility launch. 4. De-duplicate concurrent sends through `resumeRuntimeFlights` (one in-flight continuation per session id) so rapid sends do not spawn parallel PTYs against the same row. diff --git a/docs/logging.md b/docs/logging.md index 634faea76..a527b15dc 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -160,6 +160,15 @@ The public-site implementation is `apps/web/src/lib/marketingAnalytics.ts` and ` Its durable browser-local ceiling is 40 events per UTC day: 1 app open, 12 screen views, 12 conversion CTA clicks, 16 other feature clicks, 3 coarse browser error categories, and 1 budget summary. A CTA click emits only `ade_marketing_cta_clicked`, never a duplicate feature event. Per-screen/per-key caps and deduplication windows are tighter still. If durable storage is unavailable, analytics fails closed so reloads cannot bypass the budget. +The gated Windows download uses the existing CTA event with the closed +`cta_label: "download_for_windows"`, `screen: "download"`, and +`position: "download_page"` values. It carries no installer URL, release tag, +platform fingerprint, or referrer. The same CTA key is limited to three +accepted events per UTC day with a 1.5-second deduplication window, inside the +unchanged 12-CTA and 40-event public-site ceilings. Because annotated CTA +clicks suppress the companion feature event, one click still consumes one +event. + The browser sends events directly to `https://us.i.posthog.com/i/v0/e/`. It does not call a Vercel Function or Edge Function, enable Vercel Web Analytics, create a Vercel log drain, or proxy events through ADE infrastructure. PostHog therefore adds no Vercel compute, function-invocation, log-ingestion, or server-side analytics usage. The site retains only its normal static asset delivery; preview and development deployments intentionally have no PostHog environment variables so internal traffic does not spend quota or skew production data. ## Consent and kill switches diff --git a/docs/playbooks/windows-signed-release.md b/docs/playbooks/windows-signed-release.md new file mode 100644 index 000000000..1fb0cdaf7 --- /dev/null +++ b/docs/playbooks/windows-signed-release.md @@ -0,0 +1,253 @@ +# Maintainer guide: Publish signed Windows builds + +Use this guide after the Windows release changes have been merged into `main`. + +ADE already publishes macOS and standalone Mac/Linux files through GitHub Releases. The Windows release uses that same pipeline. + +After setup, a normal release is: + +1. A maintainer tags an approved commit. +2. GitHub Actions builds, signs, and checks every platform. +3. GitHub Actions creates one unpublished GitHub Release. +4. A maintainer checks it and makes it public. + +Nobody builds or uploads the Windows installer by hand. + +Each Windows release adds one file for people to download: `ADE--win-x64.exe`. It also includes two small files used by ADE's automatic updater: the `.blockmap` helps download only what changed, and `latest.yml` identifies and verifies the current installer. + +## Start here + +Complete these actions in order: + +1. Configure the Windows signing credentials in GitHub Actions. +2. Enable signed test builds while keeping Windows publication off. +3. Build a signed test version in GitHub Actions. +4. Test the installer on clean Windows 10 and Windows 11 computers. +5. Enable Windows in the production release workflow. +6. Tag the approved commit. GitHub Actions builds every platform and creates one unpublished release. +7. Check the release, make it public, and enable the Windows website link. + +The sections below provide the commands and pass conditions. + +## One-time setup + +Perform these actions once before the first public Windows release. + +### 1. Configure signing + +The current workflow supports a password-protected PFX/P12 signing certificate. If you choose a signing service that does not provide one, such as Microsoft Artifact Signing, adapt the workflow to that service before continuing. + +Add these GitHub Actions secrets: + +- `WINDOWS_CSC_LINK`: the PFX/P12 file encoded as Base64 text, or a private HTTPS URL that returns it. +- `WINDOWS_CSC_KEY_PASSWORD`: the certificate password. +- `WINDOWS_SIGNING_EXPECTED_SUBJECT`: the certificate's complete Subject value exactly as Windows reports it, such as `CN=Publisher, O=Company, C=US`. +- `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`: the approved certificate fingerprint. + +Set the complete Subject value, the fingerprint, or both. If both are set, both must match. + +```bash +gh secret set WINDOWS_CSC_LINK --repo arul28/ADE +gh secret set WINDOWS_CSC_KEY_PASSWORD --repo arul28/ADE +gh secret set WINDOWS_SIGNING_EXPECTED_SUBJECT --repo arul28/ADE +gh secret set WINDOWS_SIGNING_EXPECTED_THUMBPRINT --repo arul28/ADE +``` + +Each command asks for the secret without printing it in the command. + +Enable signed test builds, but keep Windows out of public releases: + +```bash +gh variable set ADE_WINDOWS_SIGNED_BUILD_ENABLED \ + --repo arul28/ADE --body 1 +gh variable set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \ + --repo arul28/ADE --body 0 +``` + +### 2. Run the signed build in GitHub Actions + +Use the version and commit intended for the first Windows release: + +```bash +VERSION="" +RELEASE_SHA="" +gh workflow run prepare-release.yml \ + --repo arul28/ADE \ + --ref main \ + -f version="$VERSION" \ + -f target_sha="$RELEASE_SHA" +gh run list \ + --repo arul28/ADE \ + --workflow prepare-release.yml \ + --limit 5 +``` + +Open the new run or use `gh run watch --repo arul28/ADE --exit-status`. + +The workflow checks out `RELEASE_SHA`, confirms it is on `main`, and requires the successful `ci-pass` result for that exact commit. + +The run passes only when: + +- The Windows installer is signed by the approved publisher. +- The installer and installed `ADE.exe` use the same certificate. +- The Windows installer, blockmap, and update information match. +- Required bundled tools and runtime files are present. +- The macOS and standalone runtime jobs still pass. +- No GitHub Release is created. + +Download the `ade-win-release-v` artifact from the successful run. It must contain exactly: + +- `ADE--win-x64.exe` +- `ADE--win-x64.exe.blockmap` +- `latest.yml` + +### 3. Test the signed installer + +On clean Windows 10 x64 and Windows 11 x64 computers: + +1. Check the installer signature: + + ```powershell + Get-AuthenticodeSignature ` + -LiteralPath ".\ADE--win-x64.exe" | + Format-List Status,SignerCertificate,TimeStamperCertificate + ``` + +2. Install ADE and run the same check on `%LOCALAPPDATA%\Programs\ADE\ADE.exe`. +3. Confirm both results report `Status: Valid` and the approved ADE publisher. +4. Test installation, launch, projects, lanes, agent sessions, terminals, `ade doctor`, the background service, iPhone pairing, uninstall, and reinstall. +5. Record the commit, workflow run, Windows versions, publisher, certificate fingerprint, file SHA-256, and results. + +Use the [full Windows test matrix](../development/windows-port-lane.md#external-proof-gates-before-public-availability) for the acceptance test. + +### 4. Enable Windows releases + +After the recorded test results pass: + +```bash +gh variable set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \ + --repo arul28/ADE --body 1 +``` + +This setting allows the existing release workflow to add validated Windows files to its combined draft. It does not publish a release by itself. + +## Publish a release + +Use this process for the first Windows release and every later release. + +### 1. Approve the commit and version + +Confirm: + +- The commit is on `main`. +- The normal `ci-pass` check succeeded for that exact commit. +- The version tag does not already exist. +- The signed-build and public-release settings are `1`. + +For later releases, run the non-publishing workflow for the approved version and commit. For the first Windows release, reuse the Step 2 result if its version and commit are unchanged. + +```bash +VERSION="" +RELEASE_SHA="" +gh workflow run prepare-release.yml \ + --repo arul28/ADE \ + --ref main \ + -f version="$VERSION" \ + -f target_sha="$RELEASE_SHA" +``` + +Do not tag until that run succeeds for `RELEASE_SHA`. + +### 2. Tag the approved commit + +```bash +VERSION="" +RELEASE_SHA="" +git tag -a "v$VERSION" "$RELEASE_SHA" -m "ADE v$VERSION" +git push origin "v$VERSION" +``` + +The tag starts `.github/workflows/release.yml`. GitHub Actions builds every platform and creates or updates an unpublished GitHub Release. + +Do not rerun the release workflow after the release is public. The workflow refuses to overwrite assets on a published release. + +### 3. Check the unpublished release + +```bash +gh release view "v" \ + --repo arul28/ADE \ + --json tagName,isDraft,url,assets +``` + +Require: + +- The tag points to the approved commit. +- `isDraft` is `true`. +- All existing macOS and standalone runtime files are present. +- The Windows installer, its `.blockmap`, and `latest.yml` are present. +- The downloaded Windows installer and installed `ADE.exe` have valid signatures from the approved publisher. +- The installed app points to `arul28/ADE` for updates. + +Stop if any file or check is wrong. Do not upload replacement files by hand and do not move an existing tag. Fix the source, choose a higher version, and repeat the automated process. + +### 4. Publish + +This command makes every platform's release files public: + +```bash +gh release edit "v" \ + --repo arul28/ADE --draft=false --latest +``` + +Run it only with explicit maintainer approval. + +### 5. Enable the website once + +For the first public Windows release: + +1. In the Vercel project serving `ade-app.dev`, set the Production variable `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED=1`. +2. Redeploy the current `apps/web` commit. +3. Confirm the Windows button opens the latest `arul28/ADE` GitHub Release. +4. Confirm the macOS and iOS links are unchanged. +5. Download and install ADE from that public link on a clean Windows computer. + +Later releases use the same website link and do not require another setting change. + +After a second signed Windows release exists, verify that an installed older version updates to it. This validates the updater; it is not required infrastructure for publishing the first Windows release. + +## If something fails + +- Before publication: keep Windows and the website link disabled, fix the problem, and repeat the failed test. +- Existing macOS or standalone runtime job fails: stop the release and fix the shared workflow. +- Problem found in an unpublished release: leave it unpublished, fix the source, choose a higher version, and rebuild. +- Problem found after publication: hide the website link, disable Windows publication, fix and test a higher version, then re-enable Windows before tagging it. +- Signing certificate changes: update the signing secrets and repeat the signed installer tests. +- Release workflow is rerun after publication: the workflow stops instead of replacing public files. Publish a higher version for any correction. + +Never put a certificate, private key, password, private certificate URL, or access token in the repository, logs, release notes, or test record. + +## Other distribution options + +An unsigned `.exe` can be shared for development, but Windows cannot verify who published it. The public GitHub release should use a trusted signature. + +WinGet, the Microsoft Store, MSIX, and enterprise deployment can be added later. They are separate distribution channels and are not required to add Windows to ADE's existing GitHub release. + +## Implementation files + +- [Package scripts](../../apps/desktop/package.json) +- [Windows packaging script](../../apps/desktop/scripts/run-electron-builder.mjs) +- [Windows release-file checker](../../apps/desktop/scripts/validate-win-artifacts.mjs) +- [Windows uninstall cleanup](../../apps/desktop/scripts/windows-uninstall-cleanup.ps1) +- [Non-publishing workflow](../../.github/workflows/prepare-release.yml) +- [Tag-triggered release workflow](../../.github/workflows/release.yml) +- [Shared release jobs](../../.github/workflows/release-core.yml) +- [Contract tests](../../apps/desktop/scripts/windows-release-contract.test.mjs) +- [Update behavior](../features/onboarding-and-settings/desktop-auto-update.md) + +## References + +- [electron-builder Windows signing](https://www.electron.build/docs/features/code-signing/code-signing-win/) +- [GitHub Actions secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets) +- [Microsoft code-signing options](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/code-signing-options) +- [Microsoft SmartScreen reputation](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/smartscreen-reputation) +- [Microsoft Artifact Signing](https://learn.microsoft.com/en-us/azure/artifact-signing/overview) From 47ca19f682d91312feee7a963518016a0af8c2d7 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 20:18:22 -0400 Subject: [PATCH 02/27] feat(windows): add exact-SHA release proof gate Add immutable signed-build promotion, machine-validated evidence and acceptance inventories, #999 provenance, and Windows release/support guidance. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- .github/workflows/prepare-release.yml | 13 +- .github/workflows/release-core.yml | 123 ++- WINDOWS_PORT.md | 10 +- apps/ade-cli/README.md | 2 +- apps/desktop/package.json | 4 +- .../desktop/scripts/windows-proof-indexes.mjs | 479 ++++++++++ .../scripts/windows-proof-manifest.mjs | 862 ++++++++++++++++++ .../scripts/windows-proof-manifest.test.mjs | 390 ++++++++ .../scripts/windows-release-contract.test.mjs | 46 +- docs/ARCHITECTURE.md | 2 + docs/README.md | 11 + .../windows-full-system-scenarios.json | 742 +++++++++++++++ docs/development/windows-port-lane.md | 29 +- docs/development/windows-release-proof.md | 258 ++++++ .../windows-source-provenance.json | 136 +++ docs/development/windows-support.md | 203 +++++ docs/playbooks/windows-signed-release.md | 128 ++- 17 files changed, 3390 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/scripts/windows-proof-indexes.mjs create mode 100644 apps/desktop/scripts/windows-proof-manifest.mjs create mode 100644 apps/desktop/scripts/windows-proof-manifest.test.mjs create mode 100644 docs/development/windows-full-system-scenarios.json create mode 100644 docs/development/windows-release-proof.md create mode 100644 docs/development/windows-source-provenance.json create mode 100644 docs/development/windows-support.md diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index bec56f9da..857d7bd4e 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,4 +1,4 @@ -name: Prepare release +name: Prepare signed Windows proof on: workflow_dispatch: @@ -34,9 +34,20 @@ jobs: env: INPUT_VERSION: ${{ inputs.version }} INPUT_TARGET_SHA: ${{ inputs.target_sha }} + BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} + PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} run: | set -euo pipefail + if [ "$BUILD_WINDOWS" != "1" ]; then + echo "::error::Signed Windows proof requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1." + exit 1 + fi + if [ "$PUBLISH_WINDOWS" = "1" ]; then + echo "::error::Signed Windows proof is non-publishing. Set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0 before running it." + exit 1 + fi + version="$(printf '%s' "$INPUT_VERSION" | tr -d '[:space:]')" if [ -z "$version" ]; then echo "::error::Version input cannot be empty." diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 3ec649847..a7e769f86 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -73,7 +73,12 @@ jobs: BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} + APPROVED_PROOF_SHA: ${{ vars.ADE_WINDOWS_APPROVED_PROOF_SHA }} + APPROVED_PROOF_RUN_ID: ${{ vars.ADE_WINDOWS_APPROVED_PROOF_RUN_ID }} + APPROVED_BUILD_MANIFEST_SHA256: ${{ vars.ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256 }} run: | + set -euo pipefail + if [ "$PUBLISH_WINDOWS" = "1" ] && [ "$BUILD_WINDOWS" != "1" ]; then echo "::error::ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1." exit 1 @@ -83,6 +88,27 @@ jobs: exit 1 fi + if [ "$PUBLISH_WINDOWS" = "1" ]; then + target_sha="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + proof_sha="$(printf '%s' "$APPROVED_PROOF_SHA" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" + if ! printf '%s' "$proof_sha" | grep -Eq '^[0-9a-f]{40}$'; then + echo "::error::Public Windows release requires ADE_WINDOWS_APPROVED_PROOF_SHA to contain the approved exact 40-character commit SHA." + exit 1 + fi + if [ "$proof_sha" != "$target_sha" ]; then + echo "::error::Approved Windows proof is for $proof_sha, but this release builds $target_sha." + exit 1 + fi + if ! printf '%s' "$APPROVED_PROOF_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then + echo "::error::Public Windows release requires ADE_WINDOWS_APPROVED_PROOF_RUN_ID to identify the immutable non-publishing workflow run." + exit 1 + fi + if ! printf '%s' "$APPROVED_BUILD_MANIFEST_SHA256" | grep -Eq '^[0-9a-f]{64}$'; then + echo "::error::Public Windows release requires ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256 to bind the approved artifact set." + exit 1 + fi + fi + build-mac-release: needs: - verify @@ -257,14 +283,14 @@ jobs: apps/desktop/release/latest-mac-${{ matrix.arch }}.yml if-no-files-found: error - build-win-release: - if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' }} + build-win-proof: + if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && inputs.publish == false }} needs: - verify - build-runtime-binaries runs-on: windows-latest concurrency: - group: release-${{ inputs.release_tag }}-win + group: release-${{ inputs.release_tag }}-win-proof cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -300,6 +326,7 @@ jobs: if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_SUBJECT) -and [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT)) { throw "Public Windows releases require WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT." } + - name: Download ADE runtime binaries uses: actions/download-artifact@v4 with: @@ -343,7 +370,86 @@ jobs: if ($installers.Count -ne 1) { throw "Expected exactly one signed Windows installer, found $($installers.Count)." } & apps/desktop/scripts/windows-installed-product-smoke.ps1 -InstallerPath $installers[0].FullName - - name: Upload validated Windows artifacts to workflow run + - name: Generate exact-SHA Windows proof manifest + shell: pwsh + run: >- + node apps/desktop/scripts/windows-proof-manifest.mjs create + --output apps/desktop/release/windows-proof-manifest.json + --release-dir apps/desktop/release + --target-sha "${{ inputs.target_ref }}" + --release-tag "${{ inputs.release_tag }}" + --repository "${{ github.repository }}" + --workflow-name "${{ github.workflow }}" + --workflow-run-id "${{ github.run_id }}" + --workflow-run-attempt "${{ github.run_attempt }}" + --workflow-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + - name: Validate exact-SHA Windows build proof + shell: pwsh + run: >- + node apps/desktop/scripts/windows-proof-manifest.mjs validate + --manifest apps/desktop/release/windows-proof-manifest.json + --phase build + --expected-sha "${{ inputs.target_ref }}" + --expected-tag "${{ inputs.release_tag }}" + --expected-run-id "${{ github.run_id }}" + --artifact-root apps/desktop/release + + - name: Upload validated Windows proof artifact + uses: actions/upload-artifact@v4 + with: + name: ade-win-release-${{ inputs.release_tag }} + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.exe.blockmap + apps/desktop/release/latest.yml + apps/desktop/release/windows-proof-manifest.json + if-no-files-found: error + + promote-approved-win-proof: + if: ${{ inputs.publish && vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + needs: verify + runs-on: windows-latest + concurrency: + group: release-${{ inputs.release_tag }}-win-promotion + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Download approved immutable Windows proof artifact + uses: actions/download-artifact@v4 + with: + name: ade-win-release-${{ inputs.release_tag }} + path: apps/desktop/release + github-token: ${{ github.token }} + run-id: ${{ vars.ADE_WINDOWS_APPROVED_PROOF_RUN_ID }} + + - name: Validate approved Windows artifact identity + shell: pwsh + env: + APPROVED_BUILD_MANIFEST_SHA256: ${{ vars.ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256 }} + run: | + $manifestPath = "apps/desktop/release/windows-proof-manifest.json" + $actualManifestSha = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualManifestSha -ne $env:APPROVED_BUILD_MANIFEST_SHA256) { + throw "Approved build manifest digest mismatch: expected $env:APPROVED_BUILD_MANIFEST_SHA256, received $actualManifestSha." + } + node apps/desktop/scripts/windows-proof-manifest.mjs validate ` + --manifest $manifestPath ` + --phase build ` + --expected-sha "${{ inputs.target_ref }}" ` + --expected-tag "${{ inputs.release_tag }}" ` + --expected-run-id "${{ vars.ADE_WINDOWS_APPROVED_PROOF_RUN_ID }}" ` + --artifact-root apps/desktop/release + + - name: Upload approved Windows artifacts for draft assembly uses: actions/upload-artifact@v4 with: name: ade-win-release-${{ inputs.release_tag }} @@ -351,6 +457,7 @@ jobs: apps/desktop/release/*.exe apps/desktop/release/*.exe.blockmap apps/desktop/release/latest.yml + apps/desktop/release/windows-proof-manifest.json if-no-files-found: error build-runtime-binaries: @@ -533,14 +640,14 @@ jobs: vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1' || ( vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' - && needs.build-win-release.result == 'success' + && needs.promote-approved-win-proof.result == 'success' ) ) }} needs: - build-runtime-binaries - build-mac-release - - build-win-release + - promote-approved-win-proof permissions: actions: read contents: write @@ -709,8 +816,8 @@ jobs: shopt -s nullglob # The per-arch macOS zips + latest-mac.yml are what electron-updater # consumes; DMGs are the human downloads. Mac blockmaps stay omitted. - # When the post-upgrade proof gate is enabled, Windows adds its signed - # installer, blockmap, and latest.yml as one validated draft-release set. + # Only an exact-SHA-approved Windows proof allows the public gate to + # add the signed installer, blockmap, and latest.yml as one set. files=( release-assets/mac/*.dmg release-assets/mac/*.zip diff --git a/WINDOWS_PORT.md b/WINDOWS_PORT.md index a28ecffb4..03ed04c6e 100644 --- a/WINDOWS_PORT.md +++ b/WINDOWS_PORT.md @@ -98,7 +98,11 @@ Do not enable `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` or Maintainers can follow [`docs/playbooks/windows-signed-release.md`](docs/playbooks/windows-signed-release.md) for signing, draft-release verification, publication, -and website-enable procedure. +and website-enable procedure. The exact-SHA manifest, redacted evidence layout, +and full-system acceptance inventory live in +[`docs/development/windows-release-proof.md`](docs/development/windows-release-proof.md); +installed-host diagnosis is in +[`docs/development/windows-support.md`](docs/development/windows-support.md). Four source follow-ups are explicitly outside this preview boundary: `ade brain update` continues to reject Windows because Windows as a standalone @@ -386,8 +390,8 @@ These should not block the first Windows desktop build: - Windows as a remotely installable ADE brain. - Native Windows computer use using Windows Graphics Capture/UI Automation. - Signed N-to-N+1 automatic-update testing, including cache/retry/relaunch, - scheduled-task repair, data preservation, and rejection of tampered or - incorrectly signed updates. + HKCU startup-supervisor recovery, legacy Scheduled Task cleanup, data + preservation, and rejection of tampered or incorrectly signed updates. - Windows ARM64 after all native/provider payloads are available. - Windows resource telemetry and general orphan-agent recovery. diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index a2e5b466f..a753d727a 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -103,7 +103,7 @@ The ADE brain runs as a per-user login service. The implementations live in `src | Linux | `systemctl --user` | `~/.config/systemd/user/.service` | | Windows | HKCU `Run` entry + PowerShell supervisor | `HKCU\...\CurrentVersion\Run` value `ADE Runtime (-)` | -The default service label is `com.ade.runtime`; channel builds override it via `ADE_PACKAGE_CHANNEL=alpha|beta` (`com.ade.runtime.alpha`, `com.ade.runtime.beta`). `ADE_RUNTIME_SERVICE_NAME` overrides the label outright and is used for both launchd and systemd unit names. macOS writes `launchd.{out,err}.log` under `ADE_HOME/runtime/`. +The default service label is `com.ade.runtime`; channel builds override it via `ADE_PACKAGE_CHANNEL=alpha|beta` (`com.ade.runtime.alpha`, `com.ade.runtime.beta`). `ADE_RUNTIME_SERVICE_NAME` overrides the label outright. On Windows the label and current-user identity produce a channel/user-qualified Run-value name, launcher, advisory supervisor/runtime PID record, and named pipe. The Run value starts a hidden PowerShell supervisor; a successful initialized IPC response is the separate readiness record. Scheduled Tasks are legacy state that install/uninstall clean up, never the current service registration. macOS writes `launchd.{out,err}.log` under `ADE_HOME/runtime/`. ### Windows: how the always-on guarantee is actually obtained diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 74d6aa1b3..3b1e9488e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -55,7 +55,9 @@ "test:orchestrator-smoke": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts --reporter=verbose", "test:orchestrator-complex-mock": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts -t \"complex mock prompt\" --reporter=verbose", "test:chat-model-runtime-audit": "node ./scripts/audit-chat-model-runtime.mjs --mode=dry-run --max-per-provider=2", - "test:win:release-contract": "node --test ./scripts/windows-release-contract.test.mjs ./scripts/windows-authenticode.test.mjs ./scripts/windows-uninstall-cleanup.test.mjs", + "validate:win:proof": "node ./scripts/windows-proof-manifest.mjs validate", + "validate:win:proof-inventory": "node ./scripts/windows-proof-manifest.mjs validate-inventory && node ./scripts/windows-proof-manifest.mjs validate-provenance", + "test:win:release-contract": "node --test ./scripts/windows-release-contract.test.mjs ./scripts/windows-authenticode.test.mjs ./scripts/windows-uninstall-cleanup.test.mjs ./scripts/windows-proof-manifest.test.mjs", "ade:dev": "npm --prefix ../ade-cli run dev -- --project-root ../..", "ade:build": "npm --prefix ../ade-cli run build", "ade:typecheck": "npm --prefix ../ade-cli run typecheck", diff --git a/apps/desktop/scripts/windows-proof-indexes.mjs b/apps/desktop/scripts/windows-proof-indexes.mjs new file mode 100644 index 000000000..9b2de5e5f --- /dev/null +++ b/apps/desktop/scripts/windows-proof-indexes.mjs @@ -0,0 +1,479 @@ +export const INVENTORY_SCHEMA_VERSION = "ade.windows-proof-scenarios/v1"; +export const PROVENANCE_SCHEMA_VERSION = "ade.windows-source-provenance/v1"; +export const EVIDENCE_KINDS = ["gui", "log", "db", "process", "ipc", "network"]; + +const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/; +const SAFE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const PROVIDERS = ["claude", "codex", "cursor", "opencode", "droid"]; +const PROVIDER_STATES = [ + "authenticated", "unauthenticated", "fresh", "resume", "recovery-metadata", + "recovery-instructions", "redaction", +]; +const REQUIRED_ACCEPTANCE_GATES = new Map([ + ["shell-conpty-matrix", [ + "powershell-5-1", "powershell-7", "cmd", "git-bash", "conpty-unicode", + "conpty-metacharacters", "conpty-resize", "conpty-ctrl-c", "conpty-cancel", + "descendant-cleanup", "crash-restore", + ]], + ["provider-lifecycle-matrix", PROVIDERS.flatMap((provider) => ( + PROVIDER_STATES.map((state) => `${provider}-${state}`) + ))], + ["standalone-cli-brain", [ + "ade-win32-x64", "install", "start", "status", "doctor", "update", + "remote-bootstrap", "openssh-prerequisite", "damaged-install-recovery", + ]], + ["brain-host-lifecycle", [ + "desktop-closed", "brain-crash", "brain-restart", "login", "logout", "reboot", + "repair", "reinstall", "uninstall", + ]], + ["account-oauth-directory", [ + "default-browser-callback", "encrypted-persistence", "reauthentication", "sign-out", + "existing-machine-discovery", + ]], + ["cross-machine-directions", [ + "windows-session-to-macos", "windows-session-to-physical-ios", + "windows-session-to-hosted-web", "macos-session-to-windows", + "windows-client-to-macos-linux-runtime", "macos-linux-client-to-windows-runtime", + ]], + ["transport-streaming-reconnect", [ + "lan-firewall", "tailscale", "relay", "reconnect", "terminal-streaming", + "chat-streaming", "remote-commands", + ]], + ["signed-updater-proof", [ + "signed-n-to-n-plus-one", "rfc3161-timestamp", "publisher-identity", + "tamper-rejection", "relaunch", "brain-recovery", "data-preservation", + "smartscreen-observation", + ]], + ["unchanged-release-paths", [ + "macos-desktop", "macos-runtime", "linux-runtime", "web", "relay", "ios", + ]], + ["draft-assets-and-website", [ + "installer", "blockmap", "latest-yml", "checksums", "update-metadata", + "website-link-disabled", "website-link-correct", + ]], +]); +const POST_DRAFT_GATE_IDS = new Set(["draft-assets-and-website"]); +const REQUIRED_GATE_SCENARIO_IDS = new Map([ + ["shell-conpty-matrix", "explicit-shell-conpty-matrix"], + ["provider-lifecycle-matrix", "explicit-provider-lifecycle-matrix"], + ["standalone-cli-brain", "standalone-cli-brain-lifecycle"], + ["brain-host-lifecycle", "brain-host-lifecycle-explicit"], + ["account-oauth-directory", "account-oauth-directory-explicit"], + ["cross-machine-directions", "cross-machine-directions-explicit"], + ["transport-streaming-reconnect", "transport-streaming-reconnect-explicit"], + ["signed-updater-proof", "signed-updater-proof-explicit"], + ["unchanged-release-paths", "unchanged-release-paths-explicit"], + ["draft-assets-and-website", "draft-assets-website-explicit"], +]); +const SOURCE_999_COMMITS = [ + "9b1ffc367d71b387ba0d49850d37827a1703cfce", + "236330ad9095d6e30f2068572faec5b53ae7c1b2", + "615eda5ec4a8b81c1e99f02030817dad5880878d", + "0eae1517c9b5caa67a6eb5a12ce9ddff5f50392a", + "0cfcc1c2c0f9d1c7023c2a64052463647899ca6f", + "de52986c188be8b6bc8f3f6de5c486fd53ada436", + "fb3bfe95a9b008006e54ae97b8878e9dbb1c25e5", + "7cc22ca5273f60857e1c91a6bed885e3123087d4", + "24e47be41ad942f80f423eea9e67bab25218ac0d", +]; +const REBASED_999_COMMITS = new Map([ + [SOURCE_999_COMMITS[0], "a97f9fc6e9ed0bad68428e24e8ca5126e0d46bf1"], + [SOURCE_999_COMMITS[1], "cf9e8af77919ee5b78d3b62ccd2aec15c01d2ac8"], + [SOURCE_999_COMMITS[2], "c3ab7394d275d8fcadbf2fd248cb6f39461fa553"], + [SOURCE_999_COMMITS[3], "d924e34e05f7acd1bbe210ada735dc2b4027755e"], + [SOURCE_999_COMMITS[4], "0ac7ce522fab0cba6737e76f4091ce9f4a97d064"], + [SOURCE_999_COMMITS[5], "7f3fe926bfaf1a786aaa8aa044b0e9b6585b33b4"], + [SOURCE_999_COMMITS[6], "2d6d161a7784954769c99b032cfe8a1bbabde9d0"], + [SOURCE_999_COMMITS[7], "06591c12355d5d76a548be3a3ff6178a654910a9"], + [SOURCE_999_COMMITS[8], "fc7764dd4ecf27f2c95218abf1ce0a78488812df"], +]); + +function addError(errors, field, message) { + errors.push(`${field}: ${message}`); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireObject(errors, value, field) { + if (!isPlainObject(value)) { + addError(errors, field, "must be an object"); + return false; + } + return true; +} + +function rejectUnknownKeys(errors, value, field, allowedKeys) { + if (!isPlainObject(value)) return; + for (const key of Object.keys(value)) { + if (!allowedKeys.includes(key)) addError(errors, `${field}.${key}`, "is not part of the schema"); + } +} + +function requireString(errors, value, field, pattern = null) { + if (typeof value !== "string" || value.length === 0) { + addError(errors, field, "must be a non-empty string"); + return false; + } + if (pattern && !pattern.test(value)) { + addError(errors, field, "has an invalid format"); + return false; + } + return true; +} + +function requireStringArray(errors, value, field, { allowEmpty = false, pattern = null } = {}) { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + addError(errors, field, `must be ${allowEmpty ? "an" : "a non-empty"} array`); + return false; + } + const seen = new Set(); + value.forEach((item, index) => { + if (!requireString(errors, item, `${field}[${index}]`, pattern)) return; + if (seen.has(item)) addError(errors, `${field}[${index}]`, `duplicates ${JSON.stringify(item)}`); + seen.add(item); + }); + return true; +} + +function validateDimensionCoverage(errors, inventory, dimensionName, requiredValues) { + const covered = new Set(); + for (const scenario of inventory.scenarios ?? []) { + const values = scenario?.coverage?.[dimensionName]; + if (Array.isArray(values)) values.forEach((value) => covered.add(value)); + } + for (const value of requiredValues) { + if (!covered.has(value)) { + addError(errors, `dimensions.${dimensionName}`, `${JSON.stringify(value)} is not covered by any scenario`); + } + } +} + +export function validateInventory(inventory) { + const errors = []; + if (!requireObject(errors, inventory, "inventory")) return errors; + rejectUnknownKeys(errors, inventory, "inventory", [ + "schemaVersion", "dimensions", "requiredEvidenceKinds", "acceptanceGates", "scenarios", + ]); + if (inventory.schemaVersion !== INVENTORY_SCHEMA_VERSION) { + addError(errors, "schemaVersion", `must equal ${INVENTORY_SCHEMA_VERSION}`); + } + const dimensions = inventory.dimensions; + const requiredDimensions = { + operatingSystems: ["windows-10-22h2-x64", "windows-11-x64"], + shells: ["powershell", "cmd"], + providers: ["claude", "codex", "cursor", "droid", "opencode"], + clients: ["windows-desktop", "ade-code", "hosted-web", "ios", "desktop-peer"], + routes: ["lan", "tailscale", "relay"], + journeys: ["account-oauth", "account-directory", "signed-n-to-n-plus-one-update", "windows-regressions"], + }; + if (requireObject(errors, dimensions, "dimensions")) { + rejectUnknownKeys(errors, dimensions, "dimensions", Object.keys(requiredDimensions)); + for (const [name, required] of Object.entries(requiredDimensions)) { + const values = dimensions[name]; + requireStringArray(errors, values, `dimensions.${name}`, { pattern: SAFE_ID_PATTERN }); + if (!Array.isArray(values)) continue; + for (const requiredValue of required) { + if (!values.includes(requiredValue)) { + addError(errors, `dimensions.${name}`, `must include ${JSON.stringify(requiredValue)}`); + } + } + validateDimensionCoverage(errors, inventory, name, values); + } + } + requireStringArray(errors, inventory.requiredEvidenceKinds, "requiredEvidenceKinds", { + pattern: SAFE_ID_PATTERN, + }); + if (Array.isArray(inventory.requiredEvidenceKinds)) { + for (const kind of EVIDENCE_KINDS) { + if (!inventory.requiredEvidenceKinds.includes(kind)) { + addError(errors, "requiredEvidenceKinds", `must include ${kind}`); + } + } + } + const declaredGateIds = new Set(); + if (!Array.isArray(inventory.acceptanceGates)) { + addError(errors, "acceptanceGates", "must be an array"); + } else { + inventory.acceptanceGates.forEach((gate, index) => { + const field = `acceptanceGates[${index}]`; + if (!requireObject(errors, gate, field)) return; + rejectUnknownKeys(errors, gate, field, ["id", "stage", "requirements"]); + if (requireString(errors, gate.id, `${field}.id`, SAFE_ID_PATTERN)) { + if (declaredGateIds.has(gate.id)) addError(errors, `${field}.id`, "must be unique"); + declaredGateIds.add(gate.id); + } + const expected = REQUIRED_ACCEPTANCE_GATES.get(gate.id); + const expectedStage = POST_DRAFT_GATE_IDS.has(gate.id) ? "post-draft" : "pre-tag"; + if (gate.stage !== expectedStage) { + addError(errors, `${field}.stage`, `must equal ${expectedStage}`); + } + requireStringArray(errors, gate.requirements, `${field}.requirements`, { pattern: SAFE_ID_PATTERN }); + if (!expected) { + addError(errors, `${field}.id`, "is not a required Windows acceptance gate"); + } else if (Array.isArray(gate.requirements)) { + for (const requirement of expected) { + if (!gate.requirements.includes(requirement)) { + addError(errors, `${field}.requirements`, `must include ${requirement}`); + } + } + for (const requirement of gate.requirements) { + if (!expected.includes(requirement)) { + addError(errors, `${field}.requirements`, `contains unknown ${requirement}`); + } + } + } + }); + } + for (const gateId of REQUIRED_ACCEPTANCE_GATES.keys()) { + if (!declaredGateIds.has(gateId)) addError(errors, "acceptanceGates", `is missing ${gateId}`); + } + if (!Array.isArray(inventory.scenarios) || inventory.scenarios.length === 0) { + addError(errors, "scenarios", "must be a non-empty array"); + return errors; + } + const scenarioIds = new Set(); + inventory.scenarios.forEach((scenario, index) => { + const field = `scenarios[${index}]`; + if (!requireObject(errors, scenario, field)) return; + rejectUnknownKeys(errors, scenario, field, [ + "id", "title", "hosts", "coverage", "acceptanceGateIds", "requiredEvidenceKinds", + "acceptanceRequirementIds", "passConditions", "dependencies", + ]); + if (requireString(errors, scenario.id, `${field}.id`, SAFE_ID_PATTERN)) { + if (scenarioIds.has(scenario.id)) addError(errors, `${field}.id`, "must be unique"); + scenarioIds.add(scenario.id); + } + requireString(errors, scenario.title, `${field}.title`); + if (scenario.acceptanceGateIds !== undefined) { + if (requireStringArray(errors, scenario.acceptanceGateIds, `${field}.acceptanceGateIds`, { + pattern: SAFE_ID_PATTERN, + })) { + for (const gateId of scenario.acceptanceGateIds) { + if (!declaredGateIds.has(gateId)) { + addError(errors, `${field}.acceptanceGateIds`, `${gateId} is not declared`); + } + } + const allowedRequirements = new Set(scenario.acceptanceGateIds.flatMap((gateId) => ( + REQUIRED_ACCEPTANCE_GATES.get(gateId) ?? [] + ))); + if (requireStringArray(errors, scenario.acceptanceRequirementIds, `${field}.acceptanceRequirementIds`, { + pattern: SAFE_ID_PATTERN, + })) { + for (const requirement of allowedRequirements) { + if (!scenario.acceptanceRequirementIds.includes(requirement)) { + addError(errors, `${field}.acceptanceRequirementIds`, `must include ${requirement}`); + } + } + for (const requirement of scenario.acceptanceRequirementIds) { + if (!allowedRequirements.has(requirement)) { + addError(errors, `${field}.acceptanceRequirementIds`, `contains unknown ${requirement}`); + } + } + } + } + } else if (scenario.acceptanceRequirementIds !== undefined) { + addError(errors, `${field}.acceptanceRequirementIds`, "requires acceptanceGateIds"); + } + requireStringArray(errors, scenario.hosts, `${field}.hosts`, { pattern: SAFE_ID_PATTERN }); + if (Array.isArray(scenario.hosts) && isPlainObject(dimensions)) { + const operatingSystems = Array.isArray(dimensions.operatingSystems) + ? dimensions.operatingSystems + : []; + for (const host of scenario.hosts) { + if (!operatingSystems.includes(host)) { + addError(errors, `${field}.hosts`, `${JSON.stringify(host)} is not a declared Windows host`); + } + } + } + if (requireObject(errors, scenario.coverage, `${field}.coverage`) && isPlainObject(dimensions)) { + rejectUnknownKeys(errors, scenario.coverage, `${field}.coverage`, Object.keys(dimensions)); + for (const dimensionName of Object.keys(dimensions)) { + const values = scenario.coverage[dimensionName] ?? []; + requireStringArray(errors, values, `${field}.coverage.${dimensionName}`, { + allowEmpty: true, + pattern: SAFE_ID_PATTERN, + }); + if (Array.isArray(values)) { + const declaredValues = Array.isArray(dimensions[dimensionName]) + ? dimensions[dimensionName] + : []; + for (const value of values) { + if (!declaredValues.includes(value)) { + addError(errors, `${field}.coverage.${dimensionName}`, `${JSON.stringify(value)} is not declared`); + } + } + } + } + } + if (requireStringArray(errors, scenario.requiredEvidenceKinds, `${field}.requiredEvidenceKinds`, { + pattern: SAFE_ID_PATTERN, + })) { + if (scenario.requiredEvidenceKinds.length < 2) { + addError(errors, `${field}.requiredEvidenceKinds`, "must require at least two independent signal kinds"); + } + for (const kind of scenario.requiredEvidenceKinds) { + if (!EVIDENCE_KINDS.includes(kind)) { + addError(errors, `${field}.requiredEvidenceKinds`, `${JSON.stringify(kind)} is not supported`); + } + } + } + requireStringArray(errors, scenario.passConditions, `${field}.passConditions`); + requireStringArray(errors, scenario.dependencies, `${field}.dependencies`, { allowEmpty: true }); + }); + for (const gateId of REQUIRED_ACCEPTANCE_GATES.keys()) { + const expectedScenarioId = REQUIRED_GATE_SCENARIO_IDS.get(gateId); + const boundScenarios = inventory.scenarios.filter((scenario) => ( + Array.isArray(scenario?.acceptanceGateIds) && scenario.acceptanceGateIds.includes(gateId) + )); + if (!boundScenarios.some((scenario) => scenario.id === expectedScenarioId)) { + addError(errors, "scenarios", `${gateId} must be bound by ${expectedScenarioId}`); + } + if (boundScenarios.some((scenario) => scenario.id !== expectedScenarioId)) { + addError(errors, "scenarios", `${gateId} may only be bound by ${expectedScenarioId}`); + } + } + return errors; +} + +export function validateProvenance(provenance) { + const errors = []; + if (!requireObject(errors, provenance, "provenance")) return errors; + rejectUnknownKeys(errors, provenance, "provenance", [ + "schemaVersion", "sourcePullRequest", "commitMappings", "stackLayers", + "sourceReviewDispositions", "requiredCommitTrailers", + ]); + if (provenance.schemaVersion !== PROVENANCE_SCHEMA_VERSION) { + addError(errors, "schemaVersion", `must equal ${PROVENANCE_SCHEMA_VERSION}`); + } + const source = provenance.sourcePullRequest; + if (requireObject(errors, source, "sourcePullRequest")) { + rejectUnknownKeys(errors, source, "sourcePullRequest", [ + "baseRepository", "headRepository", "number", "url", "authorName", "authorLogin", "headSha", + ]); + if (source.number !== 999) addError(errors, "sourcePullRequest.number", "must equal 999"); + if (source.headRepository !== "nsxdavid/ADE") addError(errors, "sourcePullRequest.headRepository", "must credit nsxdavid/ADE"); + if (source.baseRepository !== "arul28/ADE") addError(errors, "sourcePullRequest.baseRepository", "must identify arul28/ADE"); + if (source.authorName !== "David Whatley" || source.authorLogin !== "nsxdavid") { + addError(errors, "sourcePullRequest.author", "must credit David Whatley (nsxdavid)"); + } + if (source.url !== "https://github.com/arul28/ADE/pull/999") { + addError(errors, "sourcePullRequest.url", "must identify the canonical pull request"); + } + requireString(errors, source.headSha, "sourcePullRequest.headSha", COMMIT_SHA_PATTERN); + if (source.headSha !== SOURCE_999_COMMITS.at(-1)) { + addError(errors, "sourcePullRequest.headSha", "must equal the reviewed #999 head commit"); + } + } + if (!Array.isArray(provenance.commitMappings) || provenance.commitMappings.length === 0) { + addError(errors, "commitMappings", "must be a non-empty array"); + return errors; + } + const sourceShas = new Set(); + const rebasedShas = new Set(); + provenance.commitMappings.forEach((mapping, index) => { + const field = `commitMappings[${index}]`; + if (!requireObject(errors, mapping, field)) return; + rejectUnknownKeys(errors, mapping, field, ["sourceSha", "rebasedSha", "subject"]); + if (requireString(errors, mapping.sourceSha, `${field}.sourceSha`, COMMIT_SHA_PATTERN)) { + if (sourceShas.has(mapping.sourceSha)) addError(errors, `${field}.sourceSha`, "must be unique"); + sourceShas.add(mapping.sourceSha); + } + if (requireString(errors, mapping.rebasedSha, `${field}.rebasedSha`, COMMIT_SHA_PATTERN)) { + if (rebasedShas.has(mapping.rebasedSha)) addError(errors, `${field}.rebasedSha`, "must be unique"); + rebasedShas.add(mapping.rebasedSha); + const expected = REBASED_999_COMMITS.get(mapping.sourceSha); + if (expected && mapping.rebasedSha !== expected) { + addError(errors, `${field}.rebasedSha`, `must equal the reviewed rebased commit ${expected}`); + } + } + requireString(errors, mapping.subject, `${field}.subject`); + }); + if (provenance.commitMappings.length !== SOURCE_999_COMMITS.length) { + addError(errors, "commitMappings", `must contain all ${SOURCE_999_COMMITS.length} source commits from #999`); + } + for (const sourceSha of SOURCE_999_COMMITS) { + if (!sourceShas.has(sourceSha)) addError(errors, "commitMappings", `is missing #999 source commit ${sourceSha}`); + } + if (source?.headSha && !sourceShas.has(source.headSha)) { + addError(errors, "sourcePullRequest.headSha", "must appear in commitMappings"); + } + if (!Array.isArray(provenance.stackLayers) || provenance.stackLayers.length === 0) { + addError(errors, "stackLayers", "must be a non-empty array"); + return errors; + } + const coveredSourceShas = new Set(); + const layerIds = new Set(); + provenance.stackLayers.forEach((layer, index) => { + const field = `stackLayers[${index}]`; + if (!requireObject(errors, layer, field)) return; + rejectUnknownKeys(errors, layer, field, ["id", "purpose", "sourceCommits"]); + if (requireString(errors, layer.id, `${field}.id`, SAFE_ID_PATTERN)) { + if (layerIds.has(layer.id)) addError(errors, `${field}.id`, "must be unique"); + layerIds.add(layer.id); + } + requireString(errors, layer.purpose, `${field}.purpose`); + if (requireStringArray(errors, layer.sourceCommits, `${field}.sourceCommits`, { + pattern: COMMIT_SHA_PATTERN, + })) { + layer.sourceCommits.forEach((sha) => { + coveredSourceShas.add(sha); + if (!sourceShas.has(sha)) addError(errors, `${field}.sourceCommits`, `${sha} has no commit mapping`); + }); + } + }); + for (const sourceSha of sourceShas) { + if (!coveredSourceShas.has(sourceSha)) { + addError(errors, "stackLayers", `source commit ${sourceSha} is not attributed to any layer`); + } + } + const dispositions = provenance.sourceReviewDispositions; + if (!Array.isArray(dispositions) || dispositions.length !== 1) { + addError(errors, "sourceReviewDispositions", "must contain the original #999 Codex P2 disposition"); + } else { + const disposition = dispositions[0]; + if (requireObject(errors, disposition, "sourceReviewDispositions[0]")) { + rejectUnknownKeys(errors, disposition, "sourceReviewDispositions[0]", [ + "id", "source", "status", "finding", "disposition", "currentRegistration", + "launcher", "pidRecord", "readinessRecord", "scheduledTasks", "stackLayers", + ]); + const exactFields = { + id: "codex-p2-windows-supervisor-registration", + source: "original-999-codex-inline-p2", + status: "resolved", + currentRegistration: "hkcu-run", + launcher: "hidden-powershell-supervisor", + pidRecord: "supervisor-runtime-pids", + readinessRecord: "initialized-runtime-ipc", + scheduledTasks: "legacy-cleanup-only", + }; + for (const [field, expected] of Object.entries(exactFields)) { + if (disposition[field] !== expected) { + addError(errors, `sourceReviewDispositions[0].${field}`, `must equal ${expected}`); + } + } + requireString(errors, disposition.finding, "sourceReviewDispositions[0].finding"); + requireString(errors, disposition.disposition, "sourceReviewDispositions[0].disposition"); + if (requireStringArray(errors, disposition.stackLayers, "sourceReviewDispositions[0].stackLayers", { + pattern: SAFE_ID_PATTERN, + })) { + for (const requiredLayer of ["windows-runtime-and-ipc", "windows-proof-and-support"]) { + if (!disposition.stackLayers.includes(requiredLayer)) { + addError(errors, "sourceReviewDispositions[0].stackLayers", `must include ${requiredLayer}`); + } + } + } + } + } + if (requireObject(errors, provenance.requiredCommitTrailers, "requiredCommitTrailers")) { + rejectUnknownKeys(errors, provenance.requiredCommitTrailers, "requiredCommitTrailers", ["coAuthor", "basedOn"]); + } + if (provenance.requiredCommitTrailers?.coAuthor !== "David Whatley ") { + addError(errors, "requiredCommitTrailers.coAuthor", "must preserve David Whatley's commit credit"); + } + if (provenance.requiredCommitTrailers?.basedOn !== "nsxdavid/ADE#999") { + addError(errors, "requiredCommitTrailers.basedOn", "must equal nsxdavid/ADE#999"); + } + return errors; +} diff --git a/apps/desktop/scripts/windows-proof-manifest.mjs b/apps/desktop/scripts/windows-proof-manifest.mjs new file mode 100644 index 000000000..64cbbd113 --- /dev/null +++ b/apps/desktop/scripts/windows-proof-manifest.mjs @@ -0,0 +1,862 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + EVIDENCE_KINDS, + validateInventory, + validateProvenance, +} from "./windows-proof-indexes.mjs"; + +export { EVIDENCE_KINDS, validateInventory, validateProvenance } from "./windows-proof-indexes.mjs"; + +export const MANIFEST_SCHEMA_VERSION = "ade.windows-proof/v1"; +const RESULT_STATES = ["pending", "pass", "fail", "blocked"]; +const APPROVAL_STATES = ["proof_pending", "proof_complete", "approved"]; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/; +const RELEASE_TAG_PATTERN = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; +const SAFE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const SAFE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const SAFE_HOST_ALIAS_PATTERN = /^win(?:10|11)-[a-z0-9][a-z0-9-]*$/; +const MAX_EVIDENCE_BYTES = 20 * 1024 * 1024; +const GUI_EVIDENCE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]); +const STRUCTURED_EVIDENCE_EXTENSIONS = new Set([".csv", ".json", ".jsonl", ".log", ".txt"]); +const REDACTED_VALUE_PATTERNS = [ + { pattern: /\bBearer\s+[A-Za-z0-9._~+\/-]+=*/i, label: "bearer credential" }, + { pattern: /\b(?:gh[pousr]_|github_pat_|sk-|xox[baprs]-)[A-Za-z0-9_-]{8,}/i, label: "token-shaped value" }, + { pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/, label: "AWS access key" }, + { pattern: /[A-Z]:\\Users\\[^\\\s]+/i, label: "Windows user profile path" }, + { pattern: /\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/, label: "email address" }, + { pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/, label: "IP address" }, +]; +const FORBIDDEN_DATA_KEYS = new Set([ + "accountname", + "authorization", + "certificate", + "certificatesubject", + "credential", + "email", + "hostname", + "ip", + "ipaddress", + "machinename", + "password", + "privatekey", + "publisher", + "publishersubject", + "secret", + "token", + "username", +]); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const defaultRepoRoot = path.resolve(scriptDir, "..", "..", ".."); +const defaultInventoryPath = path.join( + defaultRepoRoot, + "docs", + "development", + "windows-full-system-scenarios.json", +); +const defaultProvenancePath = path.join( + defaultRepoRoot, + "docs", + "development", + "windows-source-provenance.json", +); + +function addError(errors, field, message) { + errors.push(`${field}: ${message}`); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireObject(errors, value, field) { + if (!isPlainObject(value)) { + addError(errors, field, "must be an object"); + return false; + } + return true; +} + +function rejectUnknownKeys(errors, value, field, allowedKeys) { + if (!isPlainObject(value)) return; + for (const key of Object.keys(value)) { + if (!allowedKeys.includes(key)) addError(errors, `${field}.${key}`, "is not part of the schema"); + } +} + +function requireString(errors, value, field, pattern = null) { + if (typeof value !== "string" || value.length === 0) { + addError(errors, field, "must be a non-empty string"); + return false; + } + if (pattern && !pattern.test(value)) { + addError(errors, field, "has an invalid format"); + return false; + } + return true; +} + +function requireBoolean(errors, value, field) { + if (typeof value !== "boolean") { + addError(errors, field, "must be a boolean"); + return false; + } + return true; +} + +function requirePositiveInteger(errors, value, field) { + if (!Number.isSafeInteger(value) || value <= 0) { + addError(errors, field, "must be a positive integer"); + return false; + } + return true; +} + +function requireIsoTimestamp(errors, value, field) { + if (!requireString(errors, value, field)) return false; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) + || Number.isNaN(Date.parse(value))) { + addError(errors, field, "must be an ISO-8601 UTC timestamp"); + return false; + } + return true; +} + +function requireStringArray(errors, value, field, { allowEmpty = false, pattern = null } = {}) { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + addError(errors, field, `must be ${allowEmpty ? "an" : "a non-empty"} array`); + return false; + } + const seen = new Set(); + value.forEach((item, index) => { + if (!requireString(errors, item, `${field}[${index}]`, pattern)) return; + if (seen.has(item)) addError(errors, `${field}[${index}]`, `duplicates ${JSON.stringify(item)}`); + seen.add(item); + }); + return true; +} + +function isSafeRelativePath(value) { + if (typeof value !== "string" || value.length === 0) return false; + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) return false; + if (value.includes("\\")) return false; + const segments = value.split("/"); + return segments.every((segment) => segment !== "" && segment !== "." && segment !== ".."); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function sha256Buffer(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function sha256File(filePath) { + const hash = createHash("sha256"); + const fd = fs.openSync(filePath, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + while (true) { + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + hash.update(buffer.subarray(0, bytesRead)); + } + } finally { + fs.closeSync(fd); + } + return hash.digest("hex"); +} + +function sha256JsonFile(filePath) { + return sha256Buffer(fs.readFileSync(filePath)); +} + +function validateNoSensitiveData(value, errors, field = "manifest") { + if (Array.isArray(value)) { + value.forEach((item, index) => validateNoSensitiveData(item, errors, `${field}[${index}]`)); + return; + } + if (isPlainObject(value)) { + for (const [key, item] of Object.entries(value)) { + if (FORBIDDEN_DATA_KEYS.has(key.toLowerCase())) { + addError(errors, `${field}.${key}`, "raw secret or personal-identifier fields are forbidden"); + } + validateNoSensitiveData(item, errors, `${field}.${key}`); + } + return; + } + if (typeof value !== "string") return; + for (const check of REDACTED_VALUE_PATTERNS) { + if (check.pattern.test(value)) { + addError(errors, field, `contains a ${check.label}; store a digest or redacted alias instead`); + } + } +} + +function findReleaseArtifacts(releaseDir) { + const entries = fs.readdirSync(releaseDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + const installers = entries.filter((name) => name.endsWith(".exe") && !name.endsWith(".exe.blockmap")); + const blockmaps = entries.filter((name) => name.endsWith(".exe.blockmap")); + const latest = entries.filter((name) => name === "latest.yml"); + if (installers.length !== 1 || blockmaps.length !== 1 || latest.length !== 1) { + throw new Error("Expected exactly one Windows installer, one installer blockmap, and latest.yml."); + } + return [ + ["installer", installers[0]], + ["blockmap", blockmaps[0]], + ["update-manifest", latest[0]], + ].map(([role, file]) => { + const filePath = path.join(releaseDir, file); + return { + role, + file, + sha256: sha256File(filePath), + sizeBytes: fs.statSync(filePath).size, + }; + }); +} + +export function createBuildManifest({ + releaseDir, + targetSha, + releaseTag, + repository, + workflowName, + workflowRunId, + workflowRunAttempt, + workflowUrl, + inventoryPath = defaultInventoryPath, + provenancePath = defaultProvenancePath, + createdAt = new Date().toISOString(), +}) { + const inventory = readJson(inventoryPath); + const inventoryErrors = validateInventory(inventory); + if (inventoryErrors.length > 0) { + throw new Error(`Scenario inventory is invalid:\n${inventoryErrors.join("\n")}`); + } + const provenance = readJson(provenancePath); + const provenanceErrors = validateProvenance(provenance); + if (provenanceErrors.length > 0) { + throw new Error(`Source provenance is invalid:\n${provenanceErrors.join("\n")}`); + } + const releaseMatch = releaseTag.match(RELEASE_TAG_PATTERN); + if (!releaseMatch) throw new Error("releaseTag must look like v1.2.3."); + if (!COMMIT_SHA_PATTERN.test(targetSha)) throw new Error("targetSha must be a lowercase 40-character commit SHA."); + if (!SAFE_REPOSITORY_PATTERN.test(repository)) throw new Error("repository must look like owner/repo."); + if (!/^\d+$/.test(String(workflowRunId)) || Number(workflowRunId) <= 0) { + throw new Error("workflowRunId must be a positive integer."); + } + if (!Number.isSafeInteger(Number(workflowRunAttempt)) || Number(workflowRunAttempt) <= 0) { + throw new Error("workflowRunAttempt must be a positive integer."); + } + + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + createdAt, + release: { + repository, + version: releaseMatch[1], + tag: releaseTag, + targetSha, + architecture: "x64", + workflow: { + name: workflowName, + event: "workflow_dispatch", + runId: String(workflowRunId), + runAttempt: Number(workflowRunAttempt), + url: workflowUrl, + publish: false, + }, + }, + buildValidation: { + signedBuild: true, + validator: "apps/desktop/scripts/validate-win-artifacts.mjs", + authenticode: "passed", + rfc3161Timestamp: "passed", + signerConsistency: "passed", + publisherPin: "passed", + }, + artifacts: findReleaseArtifacts(releaseDir), + indexes: { + scenarioInventory: { + path: "docs/development/windows-full-system-scenarios.json", + sha256: sha256JsonFile(inventoryPath), + }, + sourceProvenance: { + path: "docs/development/windows-source-provenance.json", + sha256: sha256JsonFile(provenancePath), + }, + }, + releaseGates: { + signedBuildEnabled: true, + nonPublishingWorkflow: true, + githubReleaseCreated: false, + publicReleaseEnabled: false, + websiteReleaseReady: false, + }, + approval: { + state: "proof_pending", + approvedTargetSha: null, + approverRole: null, + approvedAt: null, + }, + scenarioResults: inventory.scenarios.map((scenario) => ({ + scenarioId: scenario.id, + status: "pending", + hostAliases: [], + evidenceIds: [], + blockerCode: null, + })), + evidence: [], + }; +} + +function validateArtifactEntries(errors, artifacts, version) { + if (!Array.isArray(artifacts) || artifacts.length !== 3) { + addError(errors, "artifacts", "must contain exactly installer, blockmap, and update-manifest entries"); + return; + } + const roles = new Set(); + artifacts.forEach((artifact, index) => { + const field = `artifacts[${index}]`; + if (!requireObject(errors, artifact, field)) return; + rejectUnknownKeys(errors, artifact, field, ["role", "file", "sha256", "sizeBytes"]); + if (requireString(errors, artifact.role, `${field}.role`, SAFE_ID_PATTERN)) roles.add(artifact.role); + if (!requireString(errors, artifact.file, `${field}.file`) || !isSafeRelativePath(artifact.file) || artifact.file.includes("/")) { + addError(errors, `${field}.file`, "must be a safe top-level relative file name"); + } + requireString(errors, artifact.sha256, `${field}.sha256`, SHA256_PATTERN); + requirePositiveInteger(errors, artifact.sizeBytes, `${field}.sizeBytes`); + }); + for (const role of ["installer", "blockmap", "update-manifest"]) { + if (!roles.has(role)) addError(errors, "artifacts", `is missing role ${role}`); + } + if (typeof version === "string" && version.length > 0) { + const expectedFiles = { + installer: `ADE-${version}-win-x64.exe`, + blockmap: `ADE-${version}-win-x64.exe.blockmap`, + "update-manifest": "latest.yml", + }; + for (const artifact of artifacts) { + if (expectedFiles[artifact.role] && artifact.file !== expectedFiles[artifact.role]) { + addError(errors, `artifacts.${artifact.role}.file`, `must equal ${expectedFiles[artifact.role]}`); + } + } + } +} + +function verifyIndexedFile(errors, root, entry, field, { scanText = false } = {}) { + if (!root || !entry || !isSafeRelativePath(entry.path)) return; + const absoluteRoot = path.resolve(root); + const absolutePath = path.resolve(absoluteRoot, ...entry.path.split("/")); + const relative = path.relative(absoluteRoot, absolutePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + addError(errors, `${field}.path`, "escapes the supplied root"); + return; + } + if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) { + addError(errors, `${field}.path`, `does not exist under ${absoluteRoot}`); + return; + } + const stat = fs.statSync(absolutePath); + if (entry.sizeBytes !== stat.size) addError(errors, `${field}.sizeBytes`, `expected ${stat.size}`); + const digest = sha256File(absolutePath); + if (entry.sha256 !== digest) addError(errors, `${field}.sha256`, `does not match ${entry.path}`); + if (scanText && stat.size <= MAX_EVIDENCE_BYTES) { + validateNoSensitiveData(fs.readFileSync(absolutePath, "utf8"), errors, `${field}.content`); + } +} + +export function validateManifest(manifest, { + inventory, + provenance, + expectedSha, + expectedTag = null, + expectedWorkflowRunId = null, + phase = "build", + artifactRoot = null, + evidenceRoot = null, + inventoryPath = null, + provenancePath = null, +} = {}) { + const errors = []; + if (!requireObject(errors, manifest, "manifest")) return errors; + rejectUnknownKeys(errors, manifest, "manifest", [ + "schemaVersion", + "createdAt", + "release", + "buildValidation", + "artifacts", + "indexes", + "releaseGates", + "approval", + "scenarioResults", + "evidence", + ]); + if (manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) { + addError(errors, "schemaVersion", `must equal ${MANIFEST_SCHEMA_VERSION}`); + } + const expectedShaIsValid = requireString(errors, expectedSha, "expectedSha", COMMIT_SHA_PATTERN); + requireIsoTimestamp(errors, manifest.createdAt, "createdAt"); + if (!["build", "complete", "publication-readiness", "draft-readiness"].includes(phase)) { + addError(errors, "phase", "must be build, complete, publication-readiness, or draft-readiness"); + } + const release = manifest.release; + if (requireObject(errors, release, "release")) { + rejectUnknownKeys(errors, release, "release", [ + "repository", "version", "tag", "targetSha", "architecture", "workflow", + ]); + requireString(errors, release.repository, "release.repository", SAFE_REPOSITORY_PATTERN); + requireString(errors, release.version, "release.version"); + const tagValid = requireString(errors, release.tag, "release.tag", RELEASE_TAG_PATTERN); + if (tagValid && release.tag !== `v${release.version}`) addError(errors, "release.tag", "must match release.version"); + if (expectedTag && release.tag !== expectedTag) { + addError(errors, "release.tag", `must equal expected tag ${expectedTag}`); + } + requireString(errors, release.targetSha, "release.targetSha", COMMIT_SHA_PATTERN); + if (expectedShaIsValid && release.targetSha !== expectedSha) { + addError(errors, "release.targetSha", `must equal expected SHA ${expectedSha}`); + } + if (release.architecture !== "x64") addError(errors, "release.architecture", "must equal x64"); + const workflow = release.workflow; + if (requireObject(errors, workflow, "release.workflow")) { + rejectUnknownKeys(errors, workflow, "release.workflow", [ + "name", "event", "runId", "runAttempt", "url", "publish", + ]); + requireString(errors, workflow.name, "release.workflow.name"); + if (workflow.event !== "workflow_dispatch") addError(errors, "release.workflow.event", "must equal workflow_dispatch"); + requireString(errors, workflow.runId, "release.workflow.runId", /^\d+$/); + if (expectedWorkflowRunId && workflow.runId !== expectedWorkflowRunId) { + addError(errors, "release.workflow.runId", `must equal approved proof run ${expectedWorkflowRunId}`); + } + requirePositiveInteger(errors, workflow.runAttempt, "release.workflow.runAttempt"); + requireString(errors, workflow.url, "release.workflow.url", /^https:\/\/github\.com\//); + if (typeof workflow.url === "string" && typeof workflow.runId === "string" + && !workflow.url.endsWith(`/actions/runs/${workflow.runId}`)) { + addError(errors, "release.workflow.url", "must end with the declared workflow run id"); + } + if (workflow.publish !== false) addError(errors, "release.workflow.publish", "must be false"); + } + } + const buildValidation = manifest.buildValidation; + if (requireObject(errors, buildValidation, "buildValidation")) { + rejectUnknownKeys(errors, buildValidation, "buildValidation", [ + "signedBuild", + "validator", + "authenticode", + "rfc3161Timestamp", + "signerConsistency", + "publisherPin", + ]); + if (buildValidation.signedBuild !== true) addError(errors, "buildValidation.signedBuild", "must be true"); + if (buildValidation.validator !== "apps/desktop/scripts/validate-win-artifacts.mjs") { + addError(errors, "buildValidation.validator", "must name the canonical Windows artifact validator"); + } + for (const gate of ["authenticode", "rfc3161Timestamp", "signerConsistency", "publisherPin"]) { + if (buildValidation[gate] !== "passed") addError(errors, `buildValidation.${gate}`, "must equal passed"); + } + } + validateArtifactEntries(errors, manifest.artifacts, release?.version); + if (artifactRoot && Array.isArray(manifest.artifacts)) { + manifest.artifacts.forEach((artifact, index) => { + verifyIndexedFile(errors, artifactRoot, { ...artifact, path: artifact.file }, `artifacts[${index}]`); + }); + } else { + addError(errors, "artifactRoot", "is required so release files are independently re-hashed"); + } + + const indexes = manifest.indexes; + if (requireObject(errors, indexes, "indexes")) { + rejectUnknownKeys(errors, indexes, "indexes", ["scenarioInventory", "sourceProvenance"]); + for (const [name, expectedPath, suppliedPath] of [ + ["scenarioInventory", "docs/development/windows-full-system-scenarios.json", inventoryPath], + ["sourceProvenance", "docs/development/windows-source-provenance.json", provenancePath], + ]) { + const entry = indexes[name]; + if (requireObject(errors, entry, `indexes.${name}`)) { + rejectUnknownKeys(errors, entry, `indexes.${name}`, ["path", "sha256"]); + if (entry.path !== expectedPath) addError(errors, `indexes.${name}.path`, `must equal ${expectedPath}`); + requireString(errors, entry.sha256, `indexes.${name}.sha256`, SHA256_PATTERN); + if (suppliedPath && fs.existsSync(suppliedPath) && entry.sha256 !== sha256JsonFile(suppliedPath)) { + addError(errors, `indexes.${name}.sha256`, "does not match the supplied index file"); + } + } + } + } + const gates = manifest.releaseGates; + if (requireObject(errors, gates, "releaseGates")) { + rejectUnknownKeys(errors, gates, "releaseGates", [ + "signedBuildEnabled", + "nonPublishingWorkflow", + "githubReleaseCreated", + "publicReleaseEnabled", + "websiteReleaseReady", + ]); + if (gates.signedBuildEnabled !== true) addError(errors, "releaseGates.signedBuildEnabled", "must be true"); + if (gates.nonPublishingWorkflow !== true) addError(errors, "releaseGates.nonPublishingWorkflow", "must be true"); + for (const gate of ["githubReleaseCreated", "publicReleaseEnabled", "websiteReleaseReady"]) { + if (gates[gate] !== false) addError(errors, `releaseGates.${gate}`, "must remain false during proof validation"); + } + } + const approval = manifest.approval; + if (requireObject(errors, approval, "approval")) { + rejectUnknownKeys(errors, approval, "approval", [ + "state", "approvedTargetSha", "approverRole", "approvedAt", + ]); + if (!APPROVAL_STATES.includes(approval.state)) addError(errors, "approval.state", "is unsupported"); + if (phase === "build" && approval.state !== "proof_pending") { + addError(errors, "approval.state", "must be proof_pending during build validation"); + } + if (phase === "complete" && approval.state !== "proof_complete") { + addError(errors, "approval.state", "must equal proof_complete"); + } + if (["build", "complete"].includes(phase)) { + for (const field of ["approvedTargetSha", "approverRole", "approvedAt"]) { + if (approval[field] !== null) addError(errors, `approval.${field}`, `must be null during ${phase} validation`); + } + } + if (["publication-readiness", "draft-readiness"].includes(phase)) { + if (approval.state !== "approved") addError(errors, "approval.state", "must equal approved"); + if (approval.approvedTargetSha !== release?.targetSha) { + addError(errors, "approval.approvedTargetSha", "must equal release.targetSha"); + } + if (approval.approverRole !== "windows-release-maintainer") { + addError(errors, "approval.approverRole", "must equal windows-release-maintainer"); + } + requireIsoTimestamp(errors, approval.approvedAt, "approval.approvedAt"); + } + } + + const inventoryErrors = inventory ? validateInventory(inventory) : ["inventory: is required"]; + inventoryErrors.forEach((error) => errors.push(`inventory.${error}`)); + const provenanceErrors = provenance ? validateProvenance(provenance) : ["provenance: is required"]; + provenanceErrors.forEach((error) => errors.push(`provenance.${error}`)); + const inventoryScenarios = Array.isArray(inventory?.scenarios) ? inventory.scenarios : []; + const scenariosById = new Map(inventoryScenarios.map((scenario) => [scenario?.id, scenario])); + const postDraftGateIds = new Set( + (Array.isArray(inventory?.acceptanceGates) ? inventory.acceptanceGates : []) + .filter((gate) => gate?.stage === "post-draft" && typeof gate.id === "string") + .map((gate) => gate.id), + ); + const scenarioIsPostDraft = (scenario) => ( + Array.isArray(scenario?.acceptanceGateIds) + && scenario.acceptanceGateIds.some((gateId) => postDraftGateIds.has(gateId)) + ); + const evidenceById = new Map(); + const evidencePaths = new Map(); + const evidenceDigests = new Map(); + const coveredEvidenceKinds = new Set(); + if (!Array.isArray(manifest.evidence)) { + addError(errors, "evidence", "must be an array"); + } else { + manifest.evidence.forEach((entry, index) => { + const field = `evidence[${index}]`; + if (!requireObject(errors, entry, field)) return; + rejectUnknownKeys(errors, entry, field, [ + "id", + "kind", + "collectionMethod", + "hostAlias", + "path", + "sha256", + "sizeBytes", + "collectedAt", + "scenarioIds", + "redaction", + ]); + if (requireString(errors, entry.id, `${field}.id`, SAFE_ID_PATTERN)) { + if (evidenceById.has(entry.id)) addError(errors, `${field}.id`, "must be unique"); + evidenceById.set(entry.id, entry); + } + if (!EVIDENCE_KINDS.includes(entry.kind)) addError(errors, `${field}.kind`, "is unsupported"); + else coveredEvidenceKinds.add(entry.kind); + requireString(errors, entry.collectionMethod, `${field}.collectionMethod`, SAFE_ID_PATTERN); + requireString(errors, entry.hostAlias, `${field}.hostAlias`, SAFE_HOST_ALIAS_PATTERN); + if (!requireString(errors, entry.path, `${field}.path`) || !isSafeRelativePath(entry.path)) { + addError(errors, `${field}.path`, "must be a safe relative path using forward slashes"); + } else { + if (evidencePaths.has(entry.path)) { + addError(errors, `${field}.path`, `duplicates ${evidencePaths.get(entry.path)}; independent evidence must use a unique file`); + } else { + evidencePaths.set(entry.path, field); + } + const extension = path.posix.extname(entry.path).toLowerCase(); + const allowedExtensions = entry.kind === "gui" + ? GUI_EVIDENCE_EXTENSIONS + : STRUCTURED_EVIDENCE_EXTENSIONS; + if (!allowedExtensions.has(extension)) { + addError(errors, `${field}.path`, `${entry.kind} evidence uses a forbidden file type`); + } + } + requireString(errors, entry.sha256, `${field}.sha256`, SHA256_PATTERN); + if (typeof entry.sha256 === "string" && SHA256_PATTERN.test(entry.sha256)) { + if (evidenceDigests.has(entry.sha256)) { + addError(errors, `${field}.sha256`, `duplicates ${evidenceDigests.get(entry.sha256)}; independent evidence must have unique content`); + } else { + evidenceDigests.set(entry.sha256, field); + } + } + requirePositiveInteger(errors, entry.sizeBytes, `${field}.sizeBytes`); + if (Number.isSafeInteger(entry.sizeBytes) && entry.sizeBytes > MAX_EVIDENCE_BYTES) { + addError(errors, `${field}.sizeBytes`, `must not exceed ${MAX_EVIDENCE_BYTES} bytes`); + } + requireIsoTimestamp(errors, entry.collectedAt, `${field}.collectedAt`); + requireStringArray(errors, entry.scenarioIds, `${field}.scenarioIds`, { pattern: SAFE_ID_PATTERN }); + if (Array.isArray(entry.scenarioIds)) { + entry.scenarioIds.forEach((id) => { + if (!scenariosById.has(id)) addError(errors, `${field}.scenarioIds`, `${id} is not in the inventory`); + }); + } + if (requireObject(errors, entry.redaction, `${field}.redaction`)) { + rejectUnknownKeys(errors, entry.redaction, `${field}.redaction`, [ + "status", "containsSecrets", "containsPersonalIdentifiers", + ]); + if (entry.redaction.status !== "redacted") addError(errors, `${field}.redaction.status`, "must equal redacted"); + if (entry.redaction.containsSecrets !== false) addError(errors, `${field}.redaction.containsSecrets`, "must be false"); + if (entry.redaction.containsPersonalIdentifiers !== false) { + addError(errors, `${field}.redaction.containsPersonalIdentifiers`, "must be false"); + } + } + if (evidenceRoot) verifyIndexedFile(errors, evidenceRoot, entry, field, { + scanText: entry.kind !== "gui", + }); + }); + } + if (phase !== "build" && !evidenceRoot) { + addError(errors, "evidenceRoot", "is required after build validation"); + } + + if (!Array.isArray(manifest.scenarioResults)) { + addError(errors, "scenarioResults", "must be an array"); + } else { + const resultsById = new Map(); + manifest.scenarioResults.forEach((result, index) => { + const field = `scenarioResults[${index}]`; + if (!requireObject(errors, result, field)) return; + rejectUnknownKeys(errors, result, field, [ + "scenarioId", "status", "hostAliases", "evidenceIds", "blockerCode", + ]); + if (requireString(errors, result.scenarioId, `${field}.scenarioId`, SAFE_ID_PATTERN)) { + if (resultsById.has(result.scenarioId)) addError(errors, `${field}.scenarioId`, "must be unique"); + resultsById.set(result.scenarioId, result); + } + if (!RESULT_STATES.includes(result.status)) addError(errors, `${field}.status`, "is unsupported"); + const resultScenario = scenariosById.get(result.scenarioId); + const requiresScenarioProof = phase === "draft-readiness" + || (phase !== "build" && !scenarioIsPostDraft(resultScenario)); + requireStringArray(errors, result.hostAliases, `${field}.hostAliases`, { + allowEmpty: !requiresScenarioProof, + pattern: SAFE_HOST_ALIAS_PATTERN, + }); + requireStringArray(errors, result.evidenceIds, `${field}.evidenceIds`, { + allowEmpty: !requiresScenarioProof, + pattern: SAFE_ID_PATTERN, + }); + if (result.blockerCode !== null && !SAFE_ID_PATTERN.test(result.blockerCode ?? "")) { + addError(errors, `${field}.blockerCode`, "must be null or a redacted code"); + } + if (result.status === "blocked" && result.blockerCode === null) { + addError(errors, `${field}.blockerCode`, "is required when status is blocked"); + } else if (result.status !== "blocked" && result.blockerCode !== null) { + addError(errors, `${field}.blockerCode`, "must be null unless status is blocked"); + } + }); + for (const [scenarioId, scenario] of scenariosById) { + const result = resultsById.get(scenarioId); + if (!result) { + addError(errors, "scenarioResults", `is missing ${scenarioId}`); + continue; + } + const requiresScenarioProof = phase === "draft-readiness" + || (phase !== "build" && !scenarioIsPostDraft(scenario)); + if (requiresScenarioProof && result.status !== "pass") { + addError(errors, `scenarioResults.${scenarioId}.status`, "must equal pass"); + } + if (requiresScenarioProof) { + const resultEvidenceIds = Array.isArray(result.evidenceIds) ? result.evidenceIds : []; + const resultHostAliases = Array.isArray(result.hostAliases) + ? result.hostAliases.filter((hostAlias) => typeof hostAlias === "string") + : []; + const scenarioHosts = Array.isArray(scenario?.hosts) + ? scenario.hosts.filter((host) => typeof host === "string") + : []; + const allowedHostPrefixes = new Set(scenarioHosts.map((host) => ( + host.startsWith("windows-10") ? "win10-" : "win11-" + ))); + for (const hostAlias of resultHostAliases) { + if (![...allowedHostPrefixes].some((prefix) => hostAlias.startsWith(prefix))) { + addError(errors, `scenarioResults.${scenarioId}.hostAliases`, `${hostAlias} does not match a declared scenario host`); + } + } + for (const prefix of allowedHostPrefixes) { + if (!resultHostAliases.some((hostAlias) => hostAlias.startsWith(prefix))) { + addError(errors, `scenarioResults.${scenarioId}.hostAliases`, `is missing required ${prefix.slice(0, -1)} host evidence`); + } + } + const linkedEvidence = resultEvidenceIds.map((id) => evidenceById.get(id)).filter(Boolean); + const linkedKinds = new Set(linkedEvidence.map((entry) => entry.kind)); + for (const prefix of allowedHostPrefixes) { + if (!linkedEvidence.some((entry) => ( + typeof entry.hostAlias === "string" && entry.hostAlias.startsWith(prefix) + ))) { + addError(errors, `scenarioResults.${scenarioId}.evidenceIds`, `is missing evidence collected on a required ${prefix.slice(0, -1)} host`); + } + } + for (const evidenceId of resultEvidenceIds) { + if (!evidenceById.has(evidenceId)) { + addError(errors, `scenarioResults.${scenarioId}.evidenceIds`, `${evidenceId} is not indexed`); + } else { + const linkedEntry = evidenceById.get(evidenceId); + if (!Array.isArray(linkedEntry.scenarioIds) || !linkedEntry.scenarioIds.includes(scenarioId)) { + addError(errors, `scenarioResults.${scenarioId}.evidenceIds`, `${evidenceId} does not link back to the scenario`); + } + if (!resultHostAliases.includes(linkedEntry.hostAlias)) { + addError(errors, `scenarioResults.${scenarioId}.hostAliases`, `does not include ${evidenceId}'s host alias`); + } + } + } + const requiredKinds = Array.isArray(scenario?.requiredEvidenceKinds) + ? scenario.requiredEvidenceKinds + : []; + for (const kind of requiredKinds) { + if (!linkedKinds.has(kind)) { + addError(errors, `scenarioResults.${scenarioId}.evidenceIds`, `is missing required ${kind} evidence`); + } + } + } + } + for (const scenarioId of resultsById.keys()) { + if (!scenariosById.has(scenarioId)) addError(errors, "scenarioResults", `contains unknown ${scenarioId}`); + } + for (const entry of evidenceById.values()) { + if (!Array.isArray(entry.scenarioIds)) continue; + for (const scenarioId of entry.scenarioIds) { + const result = resultsById.get(scenarioId); + if (result && (!Array.isArray(result.evidenceIds) || !result.evidenceIds.includes(entry.id))) { + addError(errors, `evidence.${entry.id}.scenarioIds`, `${scenarioId} does not link back to the evidence`); + } + } + } + } + if (phase !== "build") { + for (const kind of EVIDENCE_KINDS) { + if (!coveredEvidenceKinds.has(kind)) addError(errors, "evidence", `is missing global ${kind} coverage`); + } + } + validateNoSensitiveData(manifest, errors); + return errors; +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + const options = {}; + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; + if (!token.startsWith("--")) throw new Error(`Unexpected argument: ${token}`); + const key = token.slice(2); + const value = rest[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for --${key}`); + options[key] = value; + index += 1; + } + return { command, options }; +} + +function throwValidation(label, errors) { + if (errors.length > 0) throw new Error(`${label} failed:\n- ${errors.join("\n- ")}`); +} + +async function main(argv) { + const { command, options } = parseArgs(argv); + if (command === "create") { + for (const required of [ + "output", + "release-dir", + "target-sha", + "release-tag", + "repository", + "workflow-name", + "workflow-run-id", + "workflow-run-attempt", + "workflow-url", + ]) { + if (!options[required]) throw new Error(`create requires --${required}`); + } + const manifest = createBuildManifest({ + releaseDir: options["release-dir"], + targetSha: options["target-sha"].toLowerCase(), + releaseTag: options["release-tag"], + repository: options.repository, + workflowName: options["workflow-name"], + workflowRunId: options["workflow-run-id"], + workflowRunAttempt: options["workflow-run-attempt"], + workflowUrl: options["workflow-url"], + inventoryPath: options.inventory ?? defaultInventoryPath, + provenancePath: options.provenance ?? defaultProvenancePath, + }); + fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); + fs.writeFileSync(options.output, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + process.stdout.write(`Created Windows proof manifest for ${manifest.release.targetSha}: ${options.output}\n`); + return; + } + if (command === "validate-inventory") { + const inventoryPath = options.inventory ?? defaultInventoryPath; + throwValidation("Windows scenario inventory validation", validateInventory(readJson(inventoryPath))); + process.stdout.write(`Validated Windows scenario inventory: ${inventoryPath}\n`); + return; + } + if (command === "validate-provenance") { + const provenancePath = options.provenance ?? defaultProvenancePath; + throwValidation("Windows source provenance validation", validateProvenance(readJson(provenancePath))); + process.stdout.write(`Validated Windows source provenance: ${provenancePath}\n`); + return; + } + if (command === "validate") { + if (!options.manifest) throw new Error("validate requires --manifest"); + const inventoryPath = options.inventory ?? defaultInventoryPath; + const provenancePath = options.provenance ?? defaultProvenancePath; + const phase = options.phase ?? "build"; + if (options["expected-sha"] && !COMMIT_SHA_PATTERN.test(options["expected-sha"])) { + throw new Error("--expected-sha must be a lowercase 40-character commit SHA."); + } + if (options["expected-tag"] && !RELEASE_TAG_PATTERN.test(options["expected-tag"])) { + throw new Error("--expected-tag must look like v1.2.3."); + } + if (options["expected-run-id"] && !/^\d+$/.test(options["expected-run-id"])) { + throw new Error("--expected-run-id must contain only decimal digits."); + } + const errors = validateManifest(readJson(options.manifest), { + inventory: readJson(inventoryPath), + provenance: readJson(provenancePath), + expectedSha: options["expected-sha"], + expectedTag: options["expected-tag"] ?? null, + expectedWorkflowRunId: options["expected-run-id"] ?? null, + phase, + artifactRoot: options["artifact-root"] ?? null, + evidenceRoot: options["evidence-root"] ?? null, + inventoryPath, + provenancePath, + }); + throwValidation(`Windows proof manifest ${phase} validation`, errors); + process.stdout.write(`Validated Windows proof manifest (${phase}): ${options.manifest}\n`); + return; + } + throw new Error( + "Usage: windows-proof-manifest.mjs [options]", + ); +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/desktop/scripts/windows-proof-manifest.test.mjs b/apps/desktop/scripts/windows-proof-manifest.test.mjs new file mode 100644 index 000000000..d9d9f8284 --- /dev/null +++ b/apps/desktop/scripts/windows-proof-manifest.test.mjs @@ -0,0 +1,390 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + EVIDENCE_KINDS, + createBuildManifest, + sha256File, + validateInventory, + validateManifest, + validateProvenance, +} from "./windows-proof-manifest.mjs"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "..", "..", ".."); +const inventoryPath = path.join(repoRoot, "docs", "development", "windows-full-system-scenarios.json"); +const provenancePath = path.join(repoRoot, "docs", "development", "windows-source-provenance.json"); +const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8")); +const provenance = JSON.parse(fs.readFileSync(provenancePath, "utf8")); +const targetSha = "0123456789abcdef0123456789abcdef01234567"; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-proof-")); + const releaseDir = path.join(root, "release"); + const evidenceRoot = path.join(root, "evidence"); + fs.mkdirSync(releaseDir); + fs.mkdirSync(evidenceRoot); + fs.writeFileSync(path.join(releaseDir, "ADE-1.2.3-win-x64.exe"), "signed installer fixture"); + fs.writeFileSync(path.join(releaseDir, "ADE-1.2.3-win-x64.exe.blockmap"), "blockmap fixture"); + fs.writeFileSync(path.join(releaseDir, "latest.yml"), "version: 1.2.3\n"); + const manifest = createBuildManifest({ + releaseDir, + targetSha, + releaseTag: "v1.2.3", + repository: "arul28/ADE", + workflowName: "Prepare release", + workflowRunId: "12345", + workflowRunAttempt: "1", + workflowUrl: "https://github.com/arul28/ADE/actions/runs/12345", + inventoryPath, + provenancePath, + createdAt: "2026-08-01T12:00:00.000Z", + }); + return { root, releaseDir, evidenceRoot, manifest }; +} + +function completeManifest(manifest, evidenceRoot) { + manifest.approval = { + state: "proof_complete", + approvedTargetSha: null, + approverRole: null, + approvedAt: null, + }; + let evidenceNumber = 0; + const evidence = []; + for (const result of manifest.scenarioResults) { + const scenario = inventory.scenarios.find((candidate) => candidate.id === result.scenarioId); + result.status = "pass"; + result.hostAliases = [...new Set(scenario.hosts.map((host) => ( + host.startsWith("windows-10") ? "win10-lab" : "win11-lab" + )))]; + result.blockerCode = null; + result.evidenceIds = []; + for (const hostAlias of result.hostAliases) { + for (const kind of scenario.requiredEvidenceKinds) { + evidenceNumber += 1; + const id = `proof-${String(evidenceNumber).padStart(4, "0")}`; + const relativePath = `${String(evidenceNumber).padStart(4, "0")}-${scenario.id}-${hostAlias}-${kind}.${kind === "gui" ? "png" : "txt"}`; + const absolutePath = path.join(evidenceRoot, ...relativePath.split("/")); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, `${scenario.id} ${hostAlias} ${kind} redacted proof\n`); + evidence.push({ + id, + kind, + collectionMethod: `${kind}-probe`, + hostAlias, + path: relativePath, + sha256: sha256File(absolutePath), + sizeBytes: fs.statSync(absolutePath).size, + collectedAt: "2026-08-01T13:00:00.000Z", + scenarioIds: [scenario.id], + redaction: { + status: "redacted", + containsSecrets: false, + containsPersonalIdentifiers: false, + }, + }); + result.evidenceIds.push(id); + } + } + } + manifest.evidence = evidence; + assert.deepEqual(new Set(evidence.map((entry) => entry.kind)), new Set(EVIDENCE_KINDS)); +} + +test("committed Windows scenario inventory covers the full declared matrix", () => { + assert.deepEqual(validateInventory(inventory), []); + const incomplete = clone(inventory); + incomplete.acceptanceGates.find((gate) => gate.id === "shell-conpty-matrix").requirements.pop(); + assert.ok(validateInventory(incomplete).some((error) => error.includes("shell-conpty-matrix") || error.includes("crash-restore"))); + const weakened = clone(inventory); + const shellScenario = weakened.scenarios.find((scenario) => scenario.id === "explicit-shell-conpty-matrix"); + shellScenario.acceptanceRequirementIds.pop(); + shellScenario.id = "generic-shell-check"; + const weakenedErrors = validateInventory(weakened); + assert.ok(weakenedErrors.some((error) => error.includes("crash-restore"))); + assert.ok(weakenedErrors.some((error) => error.includes("explicit-shell-conpty-matrix"))); +}); + +test("committed provenance maps every #999 source commit into stack layers", () => { + assert.deepEqual(validateProvenance(provenance), []); + assert.equal(provenance.sourcePullRequest.authorName, "David Whatley"); + assert.equal(provenance.commitMappings.length, 9); + assert.equal(provenance.sourceReviewDispositions[0].status, "resolved"); + + const changedMapping = clone(provenance); + changedMapping.commitMappings[0].rebasedSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + assert.ok(validateProvenance(changedMapping).some((error) => error.includes("reviewed rebased commit"))); +}); + +test("build manifest is exact-SHA, non-publishing, and artifact-bound", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + assert.deepEqual(validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + expectedTag: "v1.2.3", + expectedWorkflowRunId: "12345", + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }), []); + assert.equal(fixture.manifest.release.workflow.publish, false); + assert.equal(fixture.manifest.releaseGates.publicReleaseEnabled, false); + assert.equal(fixture.manifest.releaseGates.websiteReleaseReady, false); + + const wrongBuildIdentity = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + expectedTag: "v1.2.4", + expectedWorkflowRunId: "54321", + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(wrongBuildIdentity.some((error) => error.includes("expected tag v1.2.4"))); + assert.ok(wrongBuildIdentity.some((error) => error.includes("approved proof run 54321"))); +}); + +test("build validation rejects a different approved SHA or changed artifact", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + let errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("must equal expected SHA"))); + fs.appendFileSync(path.join(fixture.releaseDir, "ADE-1.2.3-win-x64.exe"), "changed"); + errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("artifacts[0].sha256"))); +}); + +test("complete validation re-hashes indexed evidence and enforces independent signals", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + assert.deepEqual(validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }), []); + + const firstResult = fixture.manifest.scenarioResults[0]; + const removedId = firstResult.evidenceIds.shift(); + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + const removedKind = fixture.manifest.evidence.find((entry) => entry.id === removedId).kind; + assert.ok(errors.some((error) => error.includes(`missing required ${removedKind} evidence`))); +}); + +test("complete validation rejects duplicate, sensitive, or cross-host evidence", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + + const duplicate = fixture.manifest.evidence[1]; + duplicate.path = fixture.manifest.evidence[0].path; + duplicate.sha256 = fixture.manifest.evidence[0].sha256; + duplicate.sizeBytes = fixture.manifest.evidence[0].sizeBytes; + const sensitiveEntry = fixture.manifest.evidence.find((entry) => entry !== duplicate && entry.kind !== "gui"); + const sensitivePath = path.join(fixture.evidenceRoot, ...sensitiveEntry.path.split("/")); + fs.writeFileSync(sensitivePath, "operator@example.com\n"); + sensitiveEntry.sha256 = sha256File(sensitivePath); + sensitiveEntry.sizeBytes = fs.statSync(sensitivePath).size; + const win11Result = fixture.manifest.scenarioResults.find((result) => { + const scenario = inventory.scenarios.find((candidate) => candidate.id === result.scenarioId); + return scenario.hosts.every((host) => host === "windows-11-x64"); + }); + win11Result.hostAliases = ["win10-lab"]; + + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("independent evidence must use a unique file"))); + assert.ok(errors.some((error) => error.includes("independent evidence must have unique content"))); + assert.ok(errors.some((error) => error.includes("email address"))); + assert.ok(errors.some((error) => error.includes("does not match a declared scenario host"))); + + const bothHosts = fixture.manifest.scenarioResults.find((result) => { + const scenario = inventory.scenarios.find((candidate) => candidate.id === result.scenarioId); + return scenario.hosts.includes("windows-10-22h2-x64") && scenario.hosts.includes("windows-11-x64"); + }); + bothHosts.hostAliases = ["win10-lab"]; + const missingOsErrors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(missingOsErrors.some((error) => error.includes("missing required win11 host evidence"))); +}); + +test("malformed evidence links return validation errors instead of throwing", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + fixture.manifest.evidence[0].scenarioIds = "not-an-array"; + fixture.manifest.evidence[1].hostAlias = 7; + fixture.manifest.scenarioResults[0].evidenceIds = "not-an-array"; + fixture.manifest.scenarioResults[1].hostAliases = [7]; + const malformedInventory = clone(inventory); + malformedInventory.scenarios[0].hosts = [7]; + malformedInventory.dimensions.operatingSystems = {}; + const malformedProvenance = clone(provenance); + malformedProvenance.sourceReviewDispositions[0].disposition = 7; + + assert.doesNotThrow(() => validateManifest(fixture.manifest, { + inventory: malformedInventory, + provenance: malformedProvenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + })); + const errors = validateManifest(fixture.manifest, { + inventory: malformedInventory, + provenance: malformedProvenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("evidence[0].scenarioIds"))); + assert.ok(errors.some((error) => error.includes("evidence[1].hostAlias"))); + assert.ok(errors.some((error) => error.includes("scenarioResults[0].evidenceIds"))); + assert.ok(errors.some((error) => error.includes("scenarioResults[1].hostAliases"))); + assert.ok(errors.some((error) => error.includes("scenarios[0].hosts[0]"))); + assert.ok(errors.some((error) => error.includes("dimensions.operatingSystems"))); + assert.ok(errors.some((error) => error.includes("sourceReviewDispositions[0].disposition"))); +}); + +test("manifest rejects unsafe evidence paths and obvious identifiers", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + fixture.manifest.evidence[0].path = "../outside.txt"; + fixture.manifest.operatorNote = "Captured under C:\\Users\\ExamplePerson"; + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("safe relative path"))); + assert.ok(errors.some((error) => error.includes("Windows user profile path"))); +}); + +test("publication readiness requires role-based exact-SHA approval while gates stay disabled", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + fixture.manifest.approval = { + state: "approved", + approvedTargetSha: targetSha, + approverRole: "windows-release-maintainer", + approvedAt: "2026-08-01T14:00:00.000Z", + }; + const postDraftScenario = inventory.scenarios.find((scenario) => ( + scenario.acceptanceGateIds?.includes("draft-assets-and-website") + )); + const postDraftResult = fixture.manifest.scenarioResults.find((result) => ( + result.scenarioId === postDraftScenario.id + )); + const postDraftEvidenceIds = new Set(postDraftResult.evidenceIds); + postDraftResult.status = "pending"; + postDraftResult.hostAliases = []; + postDraftResult.evidenceIds = []; + fixture.manifest.evidence = fixture.manifest.evidence.filter((entry) => !postDraftEvidenceIds.has(entry.id)); + assert.deepEqual(validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "publication-readiness", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }), []); + + const draftErrors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "draft-readiness", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(draftErrors.some((error) => error.includes(`${postDraftScenario.id}.status`))); + + const enabled = clone(fixture.manifest); + enabled.releaseGates.websiteReleaseReady = true; + const errors = validateManifest(enabled, { + inventory, + provenance, + expectedSha: targetSha, + phase: "publication-readiness", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("websiteReleaseReady"))); +}); diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index ef2bf9134..024eeebde 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -134,7 +134,7 @@ test("public Windows packaging fails closed on Authenticode signing", () => { assert.match(pkg.scripts["dist:win:signed"], /validate:win:release:signed/); assert.match(pkg.scripts["package:win:signed"], /--require-signing/); - const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); + const windowsRelease = jobBlock(releaseWorkflow, "build-win-proof", "promote-approved-win-proof"); assert.match(windowsRelease, /ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1'/); assert.match(windowsRelease, /npm run dist:win:signed/); assert.match(windowsRelease, /ADE_RELEASE_REPOSITORY:\s*\$\{\{ github\.repository \}\}/); @@ -313,13 +313,17 @@ test("Windows release assets are validated and published as one release set", () assert.match(workflowHeader, /contents: read/); assert.doesNotMatch(workflowHeader, /contents: write/); assert.match(releaseTriggerWorkflow, /permissions:\s*\n\s+actions: read\s*\n\s+checks: read\s*\n\s+contents: write/); - assert.match(publish, /- build-win-release/); + assert.match(publish, /- promote-approved-win-proof/); assert.match(publish, /permissions:\s*\n\s+actions: read\s*\n\s+contents: write/); assert.match(publish, /name: ade-win-release-/); - assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'[\s\S]*needs\.build-win-release\.result == 'success'/); + assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'[\s\S]*needs\.promote-approved-win-proof\.result == 'success'/); assert.match(verify, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1/); assert.match(verify, /ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED/); assert.match(verify, /requires approved two-version installed-update proof/); + assert.match(verify, /ADE_WINDOWS_APPROVED_PROOF_SHA/); + assert.match(verify, /ADE_WINDOWS_APPROVED_PROOF_RUN_ID/); + assert.match(verify, /ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256/); + assert.match(verify, /Approved Windows proof is for \$proof_sha, but this release builds \$target_sha/); assert.match(publish, /ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); assert.match(publish, /ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1'/); assert.match(publish, /BUILD_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED \}\}/); @@ -390,6 +394,41 @@ test("release preflight validates the exact approved commit", () => { assert.doesNotMatch(prepareWorkflow, /ref: main/); }); +test("signed Windows proof workflow is non-publishing and emits an exact-SHA manifest", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-proof", "promote-approved-win-proof"); + assert.match(prepareWorkflow, /name: Prepare signed Windows proof/); + assert.match(prepareWorkflow, /Signed Windows proof requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1/); + assert.match(prepareWorkflow, /Signed Windows proof is non-publishing/); + assert.match(prepareWorkflow, /publish: false/); + assert.doesNotMatch(prepareWorkflow, /contents: write/); + assert.match(windowsRelease, /windows-proof-manifest\.mjs create/); + assert.match(windowsRelease, /--target-sha "\$\{\{ inputs\.target_ref \}\}"/); + assert.match(windowsRelease, /windows-proof-manifest\.mjs validate/); + assert.match(windowsRelease, /--phase build/); + assert.match(windowsRelease, /--expected-sha "\$\{\{ inputs\.target_ref \}\}"/); + assert.match(windowsRelease, /--expected-tag "\$\{\{ inputs\.release_tag \}\}"/); + assert.match(windowsRelease, /--expected-run-id "\$\{\{ github\.run_id \}\}"/); + assert.match(windowsRelease, /apps\/desktop\/release\/windows-proof-manifest\.json/); +}); + +test("public Windows release promotes the approved immutable proof artifact", () => { + const windowsRelease = jobBlock(releaseWorkflow, "promote-approved-win-proof", "build-runtime-binaries"); + assert.match(windowsRelease, /name: Download approved immutable Windows proof artifact/); + assert.match(windowsRelease, /run-id: \$\{\{ vars\.ADE_WINDOWS_APPROVED_PROOF_RUN_ID \}\}/); + assert.match(windowsRelease, /name: Validate approved Windows artifact identity/); + assert.match(windowsRelease, /Get-FileHash -LiteralPath \$manifestPath -Algorithm SHA256/); + assert.match(windowsRelease, /APPROVED_BUILD_MANIFEST_SHA256/); + assert.match(windowsRelease, /--expected-run-id "\$\{\{ vars\.ADE_WINDOWS_APPROVED_PROOF_RUN_ID \}\}"/); + assert.match(windowsRelease, /inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); +}); + +test("normal public workflow skips Windows promotion while its public gate is disabled", () => { + const promotion = jobBlock(releaseWorkflow, "promote-approved-win-proof", "build-runtime-binaries"); + assert.match(promotion, /if: \$\{\{ inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \}\}/); + const publish = jobBlock(releaseWorkflow, "publish-release", null); + assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'/); +}); + test("pull requests build and smoke an unsigned Windows installer", () => { const packageJob = jobBlock(ciWorkflow, "package-win", "validate-docs"); assert.match(packageJob, /runs-on: windows-latest/); @@ -427,6 +466,7 @@ test("Windows package smoke requires every bundled provider runtime", () => { test("download page gates the Windows release and enables dedicated analytics", () => { assert.match(downloadPage, /VITE_ADE_WINDOWS_DOWNLOAD_ENABLED/); + assert.match(downloadPage, /=== "1"/); assert.match(downloadPage, /signed Windows release is approved/); assert.match(downloadPage, /MARKETING_FEATURES\.DOWNLOAD_WINDOWS/); assert.match(downloadPage, /WINDOWS_DOWNLOAD_ENABLED \? LINKS\.releasesLatest : LINKS\.releases/); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1302b007..f8be46bd3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1514,6 +1514,8 @@ Windows: - The Windows installer is an assisted, per-user, non-elevating NSIS install. It bundles the prebuilt `cr-sqlite` native binary from `apps/desktop/vendor/crsqlite/win32-x64/`, a Windows node-pty ConPTY worker, and all Darwin/Linux remote-runtime sidecars. `validate-win-artifacts.mjs` asserts each one is unpacked and executes a real CRR change through the installed DLL. The custom install step repairs the channel-specific CLI shim, user PATH, and brain startup registration; uninstall removes only registry/PATH/protocol/startup state owned by that exact installation. - Electron-builder generates `resources/app-update.yml` from the GitHub publish configuration. The source default remains the upstream `arul28/ADE`; CI sets `ADE_RELEASE_REPOSITORY=${{ github.repository }}` so fork package validation proves the installed updater authority matches the repository that built it without changing upstream release behavior. - `release-core.yml` runs the signed Windows job only when repository variable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`. If enabled, missing signing credentials or pinned publisher identity fail both the desktop and standalone-runtime signing paths and block publication; if disabled, the skipped Windows job does not block the macOS release. The installer, updater metadata, `install.ps1`, signed `ade-win32-x64.exe`, and its native archive are added to the draft only when both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`. Keep both publication gates off until clean-host install checks and the mandatory two-unpublished-version N-to-N+1 signed update proof pass; that proof includes timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. +- `release-core.yml` keeps Windows proof building and publication promotion as separate jobs. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, the non-publishing proof job requires signing credentials and a pinned publisher identity for the desktop and standalone runtime. A normal publishing workflow skips Windows cleanly while `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is off. Once the public and installed-update-proof gates are on, promotion retrieves the immutable approved proof-run artifact instead of signing or rebuilding a second set. If signed builds are disabled, the skipped Windows jobs do not block the macOS release. +- The non-publishing `prepare-release.yml` path emits `windows-proof-manifest.json` for the exact 40-character source SHA and hashes the installer, blockmap, updater manifest, standalone executable/native archive/installer/checksums, full-system scenario inventory, and #999 provenance index. Public Windows publication additionally requires the protected exact SHA, proof run id, and original build-manifest digest. Promotion validates those values plus the release tag and file hashes before draft assembly; the website download flag remains a separate disabled-by-default gate. See `docs/development/windows-release-proof.md`. - Ongoing Windows integration lane (rebase with `main`, smoke tests, backlog): `docs/development/windows-port-lane.md`. Post-packaging hardening (`apps/desktop/scripts/`): diff --git a/docs/README.md b/docs/README.md index ccaec8b88..b26e0ea70 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,12 @@ The mental model up front: ADE has a **brain** — the always-on, machine-owned 4. [**features/**](./features/) — per-feature subfolders, each with a `README.md` + detail docs. Start with `remote-runtime/`, `ade-code/`, and `sync-and-multi-device/` for the brain+clients picture. 5. [**playbooks/**](./playbooks/) — operational workflows agents can follow directly. +Windows release engineering starts with +[`development/windows-release-proof.md`](./development/windows-release-proof.md) +for the exact-SHA evidence contract and +[`development/windows-support.md`](./development/windows-support.md) for the +installed-host supervisor and troubleshooting boundary. + ## Layout ``` @@ -24,6 +30,11 @@ docs/ ├── playbooks/ │ ├── ship-lane.md # autonomous PR-to-merge driver │ └── windows-signed-release.md # signed Windows publication +├── development/ +│ ├── windows-release-proof.md # exact-SHA evidence schema + validator +│ ├── windows-full-system-scenarios.json # external Windows acceptance inventory +│ ├── windows-source-provenance.json # #999 source/rebase/stack mapping +│ └── windows-support.md # installed Windows support guide └── features/ ├── agents/ # agent identity, tools, personas ├── ade-code/ # terminal Work chat docs; source lives in apps/ade-cli/src/tuiClient diff --git a/docs/development/windows-full-system-scenarios.json b/docs/development/windows-full-system-scenarios.json new file mode 100644 index 000000000..0bdcda65c --- /dev/null +++ b/docs/development/windows-full-system-scenarios.json @@ -0,0 +1,742 @@ +{ + "schemaVersion": "ade.windows-proof-scenarios/v1", + "dimensions": { + "operatingSystems": [ + "windows-10-22h2-x64", + "windows-11-x64" + ], + "shells": [ + "powershell", + "cmd" + ], + "providers": [ + "claude", + "codex", + "cursor", + "droid", + "opencode" + ], + "clients": [ + "windows-desktop", + "ade-code", + "hosted-web", + "ios", + "desktop-peer" + ], + "routes": [ + "lan", + "tailscale", + "relay" + ], + "journeys": [ + "account-oauth", + "account-directory", + "signed-n-to-n-plus-one-update", + "windows-regressions" + ] + }, + "requiredEvidenceKinds": [ + "gui", + "log", + "db", + "process", + "ipc", + "network" + ], + "acceptanceGates": [ + { + "id": "shell-conpty-matrix", + "stage": "pre-tag", + "requirements": ["powershell-5-1", "powershell-7", "cmd", "git-bash", "conpty-unicode", "conpty-metacharacters", "conpty-resize", "conpty-ctrl-c", "conpty-cancel", "descendant-cleanup", "crash-restore"] + }, + { + "id": "provider-lifecycle-matrix", + "stage": "pre-tag", + "requirements": ["claude-authenticated", "claude-unauthenticated", "claude-fresh", "claude-resume", "claude-recovery-metadata", "claude-recovery-instructions", "claude-redaction", "codex-authenticated", "codex-unauthenticated", "codex-fresh", "codex-resume", "codex-recovery-metadata", "codex-recovery-instructions", "codex-redaction", "cursor-authenticated", "cursor-unauthenticated", "cursor-fresh", "cursor-resume", "cursor-recovery-metadata", "cursor-recovery-instructions", "cursor-redaction", "opencode-authenticated", "opencode-unauthenticated", "opencode-fresh", "opencode-resume", "opencode-recovery-metadata", "opencode-recovery-instructions", "opencode-redaction", "droid-authenticated", "droid-unauthenticated", "droid-fresh", "droid-resume", "droid-recovery-metadata", "droid-recovery-instructions", "droid-redaction"] + }, + { + "id": "standalone-cli-brain", + "stage": "pre-tag", + "requirements": ["ade-win32-x64", "install", "start", "status", "doctor", "update", "remote-bootstrap", "openssh-prerequisite", "damaged-install-recovery"] + }, + { + "id": "brain-host-lifecycle", + "stage": "pre-tag", + "requirements": ["desktop-closed", "brain-crash", "brain-restart", "login", "logout", "reboot", "repair", "reinstall", "uninstall"] + }, + { + "id": "account-oauth-directory", + "stage": "pre-tag", + "requirements": ["default-browser-callback", "encrypted-persistence", "reauthentication", "sign-out", "existing-machine-discovery"] + }, + { + "id": "cross-machine-directions", + "stage": "pre-tag", + "requirements": ["windows-session-to-macos", "windows-session-to-physical-ios", "windows-session-to-hosted-web", "macos-session-to-windows", "windows-client-to-macos-linux-runtime", "macos-linux-client-to-windows-runtime"] + }, + { + "id": "transport-streaming-reconnect", + "stage": "pre-tag", + "requirements": ["lan-firewall", "tailscale", "relay", "reconnect", "terminal-streaming", "chat-streaming", "remote-commands"] + }, + { + "id": "signed-updater-proof", + "stage": "pre-tag", + "requirements": ["signed-n-to-n-plus-one", "rfc3161-timestamp", "publisher-identity", "tamper-rejection", "relaunch", "brain-recovery", "data-preservation", "smartscreen-observation"] + }, + { + "id": "unchanged-release-paths", + "stage": "pre-tag", + "requirements": ["macos-desktop", "macos-runtime", "linux-runtime", "web", "relay", "ios"] + }, + { + "id": "draft-assets-and-website", + "stage": "post-draft", + "requirements": ["installer", "blockmap", "latest-yml", "checksums", "update-metadata", "website-link-disabled", "website-link-correct"] + } + ], + "scenarios": [ + { + "id": "clean-install-win10", + "title": "Clean standard-user install and first launch on Windows 10", + "hosts": ["windows-10-22h2-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "process", "log"], + "passConditions": [ + "A standard user installs without Node.js or elevation and reaches the project picker.", + "The installed executable and background brain start from packaged resources." + ], + "dependencies": ["Clean Windows 10 22H2 x64 standard-user host"] + }, + { + "id": "clean-install-win11", + "title": "Clean standard-user install and first launch on Windows 11", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "process", "log"], + "passConditions": [ + "A standard user installs without Node.js or elevation and reaches the project picker.", + "Start Menu relaunch attaches to the packaged background brain." + ], + "dependencies": ["Clean Windows 11 x64 standard-user host"] + }, + { + "id": "hkcu-supervisor-recovery", + "title": "HKCU startup supervisor recovers across logoff and reboot", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": ["powershell"], + "providers": [], + "clients": ["windows-desktop", "ade-code"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["process", "ipc", "log"], + "passConditions": [ + "The channel-qualified HKCU Run value launches the hidden PowerShell supervisor.", + "The supervisor and runtime PID record identify live ADE-owned processes after logon.", + "A redacted IPC readiness record proves the named pipe accepts an initialized client after logoff-logon and reboot." + ], + "dependencies": ["Permission to log off and reboot disposable hosts"] + }, + { + "id": "channel-isolation", + "title": "Stable and Beta remain isolated for one Windows user", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop", "ade-code"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["process", "ipc", "db"], + "passConditions": [ + "Stable and Beta use different startup values, ADE homes, named pipes, and databases.", + "Stopping or uninstalling one channel does not stop or delete the other." + ], + "dependencies": ["Stable and Beta signed installers for one exact source commit"] + }, + { + "id": "account-isolation", + "title": "Two Windows accounts cannot cross local runtime boundaries", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": ["powershell"], + "providers": [], + "clients": ["windows-desktop", "ade-code"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["process", "ipc", "log"], + "passConditions": [ + "Each account owns a distinct startup entry, ADE home, launcher, PID record, and named pipe.", + "The second account cannot initialize against the first account's pipe." + ], + "dependencies": ["Two non-administrator accounts on one disposable host"] + }, + { + "id": "uninstall-reinstall", + "title": "Uninstall and reinstall remove only ADE-owned integration", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": ["powershell"], + "providers": [], + "clients": ["windows-desktop", "ade-code"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["process", "log", "db"], + "passConditions": [ + "Uninstall stops the owned process tree and removes its HKCU value, launcher, shims, and owned PATH entry.", + "Legacy Scheduled Tasks are absent and unrelated PATH values and user data remain intact.", + "Reinstall starts cleanly and can reopen preserved project state." + ], + "dependencies": ["Disposable project with non-secret state"] + }, + { + "id": "providers-powershell", + "title": "All providers launch and resume through PowerShell", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": ["powershell"], + "providers": ["claude", "codex", "cursor", "droid", "opencode"], + "clients": ["windows-desktop", "ade-code"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "process", "log"], + "passConditions": [ + "Fresh launch and tracked resume work for every provider using destination-local credentials.", + "Unicode and spaces, quotes, dollar signs, percent signs, ampersands, and backticks remain literal.", + "Cancellation and child-tree cleanup leave no provider process behind." + ], + "dependencies": ["Pre-authorized test identities for each provider"] + }, + { + "id": "providers-cmd", + "title": "All providers launch and resume through cmd", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": ["cmd"], + "providers": ["claude", "codex", "cursor", "droid", "opencode"], + "clients": ["windows-desktop", "ade-code"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "process", "log"], + "passConditions": [ + "Fresh launch and tracked resume work for every provider using destination-local credentials.", + "Unicode and spaces, quotes, dollar signs, percent signs, ampersands, and backticks remain literal.", + "Resize, Ctrl+C, cancellation, and child-tree cleanup behave correctly." + ], + "dependencies": ["Pre-authorized test identities for each provider"] + }, + { + "id": "account-oauth-directory", + "title": "ADE account OAuth and account directory adoption", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop", "hosted-web"], + "routes": ["relay"], + "journeys": ["account-oauth", "account-directory"] + }, + "requiredEvidenceKinds": ["gui", "network", "log"], + "passConditions": [ + "Interactive OAuth completes without recording credentials in proof.", + "The signed-in Windows brain publishes one redacted machine alias with a verified Relay route.", + "Hosted web adopts the same-account machine with DPoP-bound credentials." + ], + "dependencies": ["External ADE account service", "Pre-authorized test account"] + }, + { + "id": "account-signout", + "title": "Account sign-out revokes cloud ownership without breaking local use", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop", "hosted-web", "ios"], + "routes": ["lan", "relay"], + "journeys": ["account-oauth", "account-directory"] + }, + "requiredEvidenceKinds": ["network", "db", "log"], + "passConditions": [ + "Sign-out closes Relay peers and removes account-directory access.", + "Local project access and locally trusted direct pairing continue according to ownership rules.", + "No account credential or device identity appears in evidence." + ], + "dependencies": ["External ADE account service", "Paired local controller"] + }, + { + "id": "desktop-peer-lan", + "title": "Cross-machine desktop client over LAN", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop", "desktop-peer"], + "routes": ["lan"], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "network", "ipc", "db"], + "passConditions": [ + "A paired desktop selects the Windows machine and a project over LAN.", + "Project, lane, and session mutations execute on the Windows brain and replicate back." + ], + "dependencies": ["Second ADE desktop on the same trusted LAN"] + }, + { + "id": "ade-code-tailscale", + "title": "ADE Code client over Tailscale", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": ["powershell", "cmd"], + "providers": ["codex"], + "clients": ["windows-desktop", "ade-code"], + "routes": ["tailscale"], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["network", "ipc", "log"], + "passConditions": [ + "ADE resolves the Windows Tailscale CLI from Program Files, PATH, or the explicit override.", + "ADE Code attaches to the intended project and receives live chat and terminal state." + ], + "dependencies": ["Two nodes on one test tailnet"] + }, + { + "id": "hosted-web-relay", + "title": "Hosted web client over Relay", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop", "hosted-web"], + "routes": ["relay"], + "journeys": ["account-directory", "windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "network", "ipc"], + "passConditions": [ + "The browser connects through the directory-selected verified Relay route.", + "Project switches and remote commands remain bound to the selected machine and project.", + "The proof contains correlation aliases, not URLs with credentials or query strings." + ], + "dependencies": ["External Relay and account-directory services"] + }, + { + "id": "ios-route-fallback", + "title": "Physical iPhone uses LAN, Tailscale, and Relay fallback", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop", "ios"], + "routes": ["lan", "tailscale", "relay"], + "journeys": ["account-directory", "windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "network", "db"], + "passConditions": [ + "A physical iPhone pairs with the Windows brain and selects the expected route order.", + "Moving off LAN proves bounded fallback to Tailscale or Relay without re-pairing.", + "Bidirectional CRR writes converge after route changes." + ], + "dependencies": ["Physical iPhone", "Test tailnet", "External Relay service"] + }, + { + "id": "crr-firewall-roundtrip", + "title": "Packaged CR-SQLite and Windows Defender Firewall roundtrip", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop", "ios"], + "routes": ["lan"], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["db", "network", "log"], + "passConditions": [ + "The installed crsqlite.dll produces and applies real changesets.", + "Firewall prompts or rules permit the approved private-network flow and do not silently open unrelated access.", + "A phone-originated and Windows-originated row converge with acknowledgements." + ], + "dependencies": ["Physical iPhone", "Disposable non-secret database rows"] + }, + { + "id": "remote-runtime-bootstrap", + "title": "Windows desktop controls supported macOS and Linux runtimes", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop", "desktop-peer"], + "routes": ["lan", "tailscale"], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "network", "process", "log"], + "passConditions": [ + "The package uploads the matching Darwin or Linux runtime and native archive over SSH.", + "Lanes, files, terminals, chat, browser, and App Control remain owned by the remote runtime." + ], + "dependencies": ["Supported macOS runtime host", "Supported Linux runtime host"] + }, + { + "id": "signed-update-n-to-n-plus-one", + "title": "Signed installed N to signed N+1 automatic update", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop"], + "routes": ["lan"], + "journeys": ["signed-n-to-n-plus-one-update"] + }, + "requiredEvidenceKinds": ["gui", "log", "process", "network"], + "passConditions": [ + "Installed signed version N discovers only the approved N+1 repository metadata.", + "The updater verifies, hands off, relaunches on N+1, and preserves project data and the HKCU supervisor.", + "Installer and installed executable at both versions use the approved pinned signer." + ], + "dependencies": ["Two signed non-public Windows builds", "Private update feed matching the approved repository"] + }, + { + "id": "update-rejection-recovery", + "title": "Updater rejects tampering and recovers from interrupted handoff", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop"], + "routes": ["lan"], + "journeys": ["signed-n-to-n-plus-one-update", "windows-regressions"] + }, + "requiredEvidenceKinds": ["log", "process", "network"], + "passConditions": [ + "Checksum or signature mismatch cannot install or replace the running app.", + "Interrupted handoff reports retry state, preserves the verified cache once, and clears it after repeated failure.", + "The old version remains launchable with intact data." + ], + "dependencies": ["Controlled private update feed", "Disposable interrupted-update host snapshot"] + }, + { + "id": "projects-lanes-git", + "title": "Projects, lanes, Git, files, and conflict regression pass", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": ["powershell", "cmd"], + "providers": [], + "clients": ["windows-desktop"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "log", "db", "process"], + "passConditions": [ + "Open and create projects, then create, commit, rebase, conflict-resolve, archive, and delete lanes.", + "Drive-letter, UNC, junction, Unicode, and spaced paths remain inside the intended worktree.", + "Files, diffs, history, and PR projections refresh from canonical runtime state." + ], + "dependencies": ["Disposable Git repository with synthetic conflicts"] + }, + { + "id": "terminal-process-regressions", + "title": "ConPTY resize, input, cancellation, and process-tree regressions pass", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": ["powershell", "cmd"], + "providers": [], + "clients": ["windows-desktop", "ade-code"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "process", "log"], + "passConditions": [ + "PowerShell and cmd PTYs accept Unicode, resize, Ctrl+C, and cancellation.", + "Stopping a session terminates descendants without visible probe consoles or orphan processes.", + "Resume uses the lane-owning runtime's shell and provider descriptor." + ], + "dependencies": [] + }, + { + "id": "browser-app-control-proof", + "title": "Built-in Browser, App Control, and proof ingestion regressions pass", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": ["powershell"], + "providers": ["codex"], + "clients": ["windows-desktop"], + "routes": ["lan"], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "ipc", "network", "log"], + "passConditions": [ + "Built-in Browser navigation and downloads work without claiming native Windows computer use.", + "App Control launches a developer-owned Electron app, attaches over CDP, and accepts input.", + "Intentional screenshots, traces, and redacted logs ingest through the proof broker." + ], + "dependencies": ["Local non-secret Electron fixture app"] + }, + { + "id": "deep-links-file-associations-path", + "title": "Deep links, file associations, CLI shim, and PATH regressions pass", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": ["powershell", "cmd"], + "providers": [], + "clients": ["windows-desktop", "ade-code"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "process", "ipc"], + "passConditions": [ + "Cold and hot ade links route to the requested surface.", + "File association opens the intended project without path corruption.", + "A newly launched shell resolves only the installer-owned ade shim." + ], + "dependencies": [] + }, + { + "id": "windows-ui-accessibility", + "title": "Window chrome, DPI, monitor, contrast, and keyboard regressions pass", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": [], + "providers": [], + "clients": ["windows-desktop"], + "routes": [], + "journeys": ["windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "log"], + "passConditions": [ + "Caption controls, dragging, double-click maximize, and Snap Layouts work.", + "Core screens remain usable at 100, 125, 150, and 200 percent scaling, on multiple monitors, in high contrast, and by keyboard.", + "macOS-only controls remain hidden or capability-blocked." + ], + "dependencies": ["Host or VM capable of the required display configurations"] + }, + { + "id": "authenticode-artifact-identity", + "title": "Installer, installed app, blockmap, and updater metadata share one release identity", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], + "shells": ["powershell"], + "providers": [], + "clients": ["windows-desktop"], + "routes": [], + "journeys": ["signed-n-to-n-plus-one-update"] + }, + "requiredEvidenceKinds": ["log", "process"], + "passConditions": [ + "Installer and installed executable signatures are valid, timestamped, pinned, and consistent.", + "The installer digest matches the exact-SHA manifest and latest.yml references the matching installer and blockmap.", + "Evidence stores digests of publisher identity fields rather than raw certificate details." + ], + "dependencies": ["Approved organizational Authenticode certificate"] + }, + { + "id": "cross-machine-handoff", + "title": "Cross-machine Work chat handoff preserves exact Git and route contracts", + "hosts": ["windows-11-x64"], + "coverage": { + "operatingSystems": ["windows-11-x64"], + "shells": [], + "providers": ["claude", "codex", "opencode"], + "clients": ["windows-desktop", "desktop-peer"], + "routes": ["tailscale", "relay"], + "journeys": ["account-directory", "windows-regressions"] + }, + "requiredEvidenceKinds": ["gui", "network", "ipc", "db"], + "passConditions": [ + "A clean published source lane transfers only the bounded capsule at the reviewed route.", + "The destination validates the exact commit, creates or reuses the correct lane, and dispatches idempotently.", + "Secrets, transcripts, artifact bytes, PTYs, and dirty worktree content do not cross machines." + ], + "dependencies": ["Second compatible ADE desktop", "Published synthetic test branch"] + }, + { + "id": "explicit-shell-conpty-matrix", + "title": "PowerShell 5.1, PowerShell 7, cmd, and Git Bash ConPTY matrix", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell", "cmd"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, + "acceptanceGateIds": ["shell-conpty-matrix"], + "acceptanceRequirementIds": ["powershell-5-1", "powershell-7", "cmd", "git-bash", "conpty-unicode", "conpty-metacharacters", "conpty-resize", "conpty-ctrl-c", "conpty-cancel", "descendant-cleanup", "crash-restore"], + "requiredEvidenceKinds": ["gui", "process", "log"], + "passConditions": [ + "PowerShell 5.1, PowerShell 7, cmd, and Git Bash each launch as separately identified shell cases on both Windows versions.", + "Every shell case proves ConPTY Unicode and metacharacter round trips, resize, Ctrl+C, explicit cancellation, descendant process-tree cleanup, and crash restore.", + "No shell is inferred from the generic powershell label; evidence records the exact executable family with paths redacted." + ], + "dependencies": ["PowerShell 7 and Git for Windows installed on disposable hosts"] + }, + { + "id": "explicit-provider-lifecycle-matrix", + "title": "Real provider authentication and recovery matrix", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell", "cmd"], "providers": ["claude", "codex", "cursor", "droid", "opencode"], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, + "acceptanceGateIds": ["provider-lifecycle-matrix"], + "acceptanceRequirementIds": ["claude-authenticated", "claude-unauthenticated", "claude-fresh", "claude-resume", "claude-recovery-metadata", "claude-recovery-instructions", "claude-redaction", "codex-authenticated", "codex-unauthenticated", "codex-fresh", "codex-resume", "codex-recovery-metadata", "codex-recovery-instructions", "codex-redaction", "cursor-authenticated", "cursor-unauthenticated", "cursor-fresh", "cursor-resume", "cursor-recovery-metadata", "cursor-recovery-instructions", "cursor-redaction", "opencode-authenticated", "opencode-unauthenticated", "opencode-fresh", "opencode-resume", "opencode-recovery-metadata", "opencode-recovery-instructions", "opencode-redaction", "droid-authenticated", "droid-unauthenticated", "droid-fresh", "droid-resume", "droid-recovery-metadata", "droid-recovery-instructions", "droid-redaction"], + "requiredEvidenceKinds": ["gui", "log", "process", "db"], + "passConditions": [ + "Claude, Codex, Cursor, OpenCode, and Droid each prove authenticated and unauthenticated states plus fresh and resumed real sessions.", + "Each provider proves crash/interruption recovery metadata and actionable recovery instructions without silently fabricating resume success.", + "Provider evidence is independently redacted for account identifiers, prompts, transcripts, tokens, command arguments, and local paths." + ], + "dependencies": ["Authorized disposable provider test identities; no credentials retained in proof"] + }, + { + "id": "standalone-cli-brain-lifecycle", + "title": "Standalone ade-win32-x64 CLI and brain lifecycle", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell", "cmd"], "providers": [], "clients": ["ade-code"], "routes": ["lan", "tailscale"], "journeys": ["windows-regressions"]}, + "acceptanceGateIds": ["standalone-cli-brain"], + "acceptanceRequirementIds": ["ade-win32-x64", "install", "start", "status", "doctor", "update", "remote-bootstrap", "openssh-prerequisite", "damaged-install-recovery"], + "requiredEvidenceKinds": ["process", "ipc", "log", "network"], + "passConditions": [ + "After the standalone ade-win32-x64 implementation exists, its payload installs and proves brain start, status, doctor, and update without the desktop process.", + "Remote bootstrap proves the OpenSSH prerequisite and an actionable missing-OpenSSH failure before a successful supported macOS/Linux bootstrap.", + "A damaged or partial standalone install produces bounded recovery guidance and recovers without deleting project data." + ], + "dependencies": ["BLOCKING IMPLEMENTATION: ADE does not yet build ade-win32-x64 and brain update currently rejects Windows", "Supported macOS and Linux bootstrap hosts", "Disposable damaged-install snapshot"] + }, + { + "id": "brain-host-lifecycle-explicit", + "title": "Closed desktop, brain crash, login, logout, reboot, repair, reinstall, and uninstall", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, + "acceptanceGateIds": ["brain-host-lifecycle"], + "acceptanceRequirementIds": ["desktop-closed", "brain-crash", "brain-restart", "login", "logout", "reboot", "repair", "reinstall", "uninstall"], + "requiredEvidenceKinds": ["process", "ipc", "log", "db"], + "passConditions": [ + "With the Windows desktop closed, the brain remains ready; an injected brain crash is detected, then supported explicit ade brain start or repair restarts it during the same login session.", + "Login, logout, and reboot each produce a new advisory supervisor/runtime PID record and a separate initialized IPC readiness record.", + "Repair is exercised separately from reinstall and uninstall; each preserves or removes only the data and integration it owns." + ], + "dependencies": ["Disposable host snapshots and permission to log out and reboot"] + }, + { + "id": "account-oauth-directory-explicit", + "title": "Default-browser OAuth, encrypted persistence, reauthentication, sign-out, and directory discovery", + "hosts": ["windows-11-x64"], + "coverage": {"operatingSystems": ["windows-11-x64"], "shells": [], "providers": [], "clients": ["windows-desktop", "hosted-web", "ios"], "routes": ["lan", "tailscale", "relay"], "journeys": ["account-oauth", "account-directory"]}, + "acceptanceGateIds": ["account-oauth-directory"], + "acceptanceRequirementIds": ["default-browser-callback", "encrypted-persistence", "reauthentication", "sign-out", "existing-machine-discovery"], + "requiredEvidenceKinds": ["gui", "db", "ipc", "network"], + "passConditions": [ + "OAuth launches the Windows default browser, validates its callback, and stores the resulting account state only in the encrypted credential path.", + "Expired authorization triggers reauthentication; sign-out clears local account and Relay authority without deleting unrelated project data.", + "A different already-authorized client discovers the existing Windows machine through the account directory without exposing machine identifiers in proof." + ], + "dependencies": ["Authorized disposable ADE account and external account-directory/OAuth services"] + }, + { + "id": "cross-machine-directions-explicit", + "title": "Bidirectional client, session, and runtime direction matrix", + "hosts": ["windows-11-x64"], + "coverage": {"operatingSystems": ["windows-11-x64"], "shells": [], "providers": ["claude", "codex", "opencode"], "clients": ["windows-desktop", "desktop-peer", "hosted-web", "ios", "ade-code"], "routes": ["lan", "tailscale", "relay"], "journeys": ["account-directory", "windows-regressions"]}, + "acceptanceGateIds": ["cross-machine-directions"], + "acceptanceRequirementIds": ["windows-session-to-macos", "windows-session-to-physical-ios", "windows-session-to-hosted-web", "macos-session-to-windows", "windows-client-to-macos-linux-runtime", "macos-linux-client-to-windows-runtime"], + "requiredEvidenceKinds": ["gui", "ipc", "network", "db"], + "passConditions": [ + "Windows-originated sessions are visible and controllable from macOS desktop, physical iOS, and hosted web; macOS-originated sessions are visible and controllable from Windows.", + "The Windows client controls supported macOS and Linux runtimes, and macOS and Linux ADE clients control the Windows runtime.", + "Every direction preserves owning machine, project, lane, session, and command authority across reconnect." + ], + "dependencies": ["Supported macOS and Linux hosts", "Physical iPhone", "Hosted web test deployment"] + }, + { + "id": "transport-streaming-reconnect-explicit", + "title": "LAN firewall, Tailscale, Relay, reconnect, streaming, and remote-command matrix", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell", "cmd"], "providers": ["codex"], "clients": ["windows-desktop", "ade-code", "hosted-web", "ios", "desktop-peer"], "routes": ["lan", "tailscale", "relay"], "journeys": ["windows-regressions"]}, + "acceptanceGateIds": ["transport-streaming-reconnect"], + "acceptanceRequirementIds": ["lan-firewall", "tailscale", "relay", "reconnect", "terminal-streaming", "chat-streaming", "remote-commands"], + "requiredEvidenceKinds": ["network", "ipc", "gui", "log"], + "passConditions": [ + "LAN proves the intended Windows Firewall scope, then Tailscale and Relay each prove authenticated connection and route truth.", + "Each route interruption reconnects within bounds while terminal streaming, chat streaming, and remote commands resume without duplication or cross-machine drift.", + "Network evidence records route class, timing, and bounded outcome only; no IP, hostname, Relay URL, or credential is retained." + ], + "dependencies": ["Test tailnet", "External Relay", "Firewall-policy-controlled disposable hosts"] + }, + { + "id": "signed-updater-proof-explicit", + "title": "Signed N to N+1 identity, tamper, relaunch, recovery, preservation, and SmartScreen", + "hosts": ["windows-10-22h2-x64", "windows-11-x64"], + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell"], "providers": [], "clients": ["windows-desktop"], "routes": ["lan"], "journeys": ["signed-n-to-n-plus-one-update"]}, + "acceptanceGateIds": ["signed-updater-proof"], + "acceptanceRequirementIds": ["signed-n-to-n-plus-one", "rfc3161-timestamp", "publisher-identity", "tamper-rejection", "relaunch", "brain-recovery", "data-preservation", "smartscreen-observation"], + "requiredEvidenceKinds": ["gui", "log", "process", "network", "db"], + "passConditions": [ + "Signed N and N+1 installer/app identities have a valid RFC3161 timestamp and the same approved publisher digest; SmartScreen behavior is recorded factually on both Windows versions.", + "Valid N to N+1 update verifies metadata, relaunches on N+1, restores brain readiness, and preserves project, account, lane, and preference data.", + "Tampered installer, blockmap, or update metadata is rejected while N remains launchable and no unverified replacement is installed." + ], + "dependencies": ["Two signed non-public builds", "Private update feed", "Clean or reset SmartScreen test state"] + }, + { + "id": "unchanged-release-paths-explicit", + "title": "Existing non-Windows release paths remain unchanged", + "hosts": ["windows-11-x64"], + "coverage": {"operatingSystems": ["windows-11-x64"], "shells": [], "providers": [], "clients": ["windows-desktop", "hosted-web", "ios", "desktop-peer"], "routes": ["relay"], "journeys": ["windows-regressions"]}, + "acceptanceGateIds": ["unchanged-release-paths"], + "acceptanceRequirementIds": ["macos-desktop", "macos-runtime", "linux-runtime", "web", "relay", "ios"], + "requiredEvidenceKinds": ["log", "network", "process"], + "passConditions": [ + "The unchanged macOS desktop, macOS runtime, Linux runtime, web, Relay, and iOS build/release validation paths all pass at the exact target SHA.", + "Windows proof and promotion do not rename, omit, republish, or alter the permissions and publication behavior of those paths." + ], + "dependencies": ["CI results for every existing release path"] + }, + { + "id": "draft-assets-website-explicit", + "title": "Complete draft assets and disabled but correct Windows website link", + "hosts": ["windows-11-x64"], + "coverage": {"operatingSystems": ["windows-11-x64"], "shells": [], "providers": [], "clients": ["windows-desktop", "hosted-web"], "routes": [], "journeys": ["signed-n-to-n-plus-one-update"]}, + "acceptanceGateIds": ["draft-assets-and-website"], + "acceptanceRequirementIds": ["installer", "blockmap", "latest-yml", "checksums", "update-metadata", "website-link-disabled", "website-link-correct"], + "requiredEvidenceKinds": ["gui", "log", "network"], + "passConditions": [ + "The unpublished draft has the complete installer, blockmap, and latest.yml set; all checksums match the approved manifest and update metadata names the same version and files.", + "The Windows website download control remains disabled before publication, while its hidden target resolves to the approved canonical release location and analytics identity.", + "No proof workflow creates a GitHub Release, publishes an asset, or enables the website." + ], + "dependencies": ["Authorized read-only draft inspection", "Built website configuration without deployment"] + } + ] +} diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index bc0c7dc1d..7da6d1243 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -47,7 +47,7 @@ These are the foundations that should stay merged from this lane (see also `docs | **Installers** | The assisted NSIS installer is explicitly per-user and non-elevating. Its custom install step repairs the channel-aware CLI shim, current-user `PATH`, and brain startup registration; uninstall removes only the terminal shim, PATH/protocol/association/startup state owned by that installation. Stable/Beta/Alpha use distinct executable, app, and shim names. Windows packages carry all Darwin/Linux remote-runtime sidecars. Electron-builder owns `app-update.yml`; CI binds it to `${{ github.repository }}` and package smoke verifies the authority. | | **Standalone brain** | Releases build `ade-win32-x64.exe` plus a native dependency archive and checksum them with all other runtime artifacts. `install.ps1` stages and verifies both, installs the current-user PATH/service, and rolls back on failure. `ade brain start/status/doctor/update` support Windows; self-update stops the running executable before replacement and restores the previous runtime/service on failure. | | **Remote SSH runtime** | Windows 10 22H2 and Windows 11 x64 are native SSH-bootstrap targets through Windows OpenSSH Server. Bootstrap uses encoded PowerShell plus JSON stdin, verified SFTP uploads for `ade-win32-x64.exe`, native dependencies, the PTY worker, and agent skills, then launches the channel-specific named-pipe runtime through `ade rpc --stdio`. PowerShell 5.1+ and `tar.exe` are prerequisites; WSL, ARM64, and Windows Server remain excluded from Windows v1. | -| **CI/release** | `ci.yml` has a required `windows-latest` package job that builds and smokes an unsigned preview, including fresh install, repair, reinstall, PATH/startup/deep-link/file-association ownership, and uninstall. `release-core.yml` enables the signed job only with `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`; the Windows signing-secret contract is exactly `WINDOWS_CSC_LINK`, `WINDOWS_CSC_KEY_PASSWORD`, `WINDOWS_SIGNING_EXPECTED_SUBJECT`, and `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`, with a trusted RFC3161 timestamp and the approved identity required for the installer, installed app, and standalone runtime (thumbprint equality is enforced whenever a thumbprint is configured). The non-publishing run retains a checksum-covered standalone proof bundle for offline clean-host installation. While public Windows publication is disabled, a failed or skipped signed Windows test build cannot block the existing macOS release. Public Windows assets additionally require both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`; the latter attests to the mandatory clean-host, two-unpublished-version signed N-to-N+1 updater proof. | +| **CI/release** | `ci.yml` has a required `windows-latest` package job that builds and smokes an unsigned preview, including installed-product lifecycle checks. With publication disabled, `release-core.yml` uses only the canonical `WINDOWS_*` signing contract to build one signed proof artifact containing the desktop and standalone runtime, requires a trusted RFC3161 timestamp and pinned identity, and fails closed. Public release retrieves that immutable artifact and verifies its run id, manifest digest, tag, exact source SHA, checksums, and installed-update approval rather than rebuilding. Disabled Windows publication never blocks the macOS release path. | | **Sync / Tailscale** | `resolveTailscaleCliPath` (shared): macOS bundle, Windows `Program Files`\\Tailscale, then `PATH`. | ## Mainline feature areas to smoke-test on Windows after each rebase @@ -82,12 +82,12 @@ verification. Complete these before enabling the public website/release flags: relaunch, repair, `ade brain start/status/doctor/update`, logoff/logon brain recovery, deep links/file associations, and no orphaned legacy task/launcher. 2. **Channel/user isolation** — run Stable and Beta side by side and verify - separate tasks, ADE homes, runtime/desktop-bridge pipes, and project state; + separate HKCU Run values, ADE homes, runtime/desktop-bridge pipes, and project state; repeat with a second Windows account. 3. **Provider/PTY matrix** — fresh launch and resume for Claude, Codex, Cursor, - Droid, and OpenCode in PowerShell and cmd, including Unicode and paths or + Droid, and OpenCode in PowerShell 5.1, PowerShell 7, cmd, and Git Bash, including Unicode and paths or prompts containing spaces, quotes, `$`, `%`, `&`, and backticks. Exercise - resize, Ctrl+C, cancellation, and child-tree cleanup. + authenticated/unauthenticated state, resize, Ctrl+C, cancellation, child-tree cleanup, crash restore, recovery metadata/instructions, and redaction. 4. **Sync and firewall** — pair a physical iPhone, prove bidirectional CRR changes, verify Windows Defender Firewall behavior on LAN, then exercise Tailscale/Relay fallback. For a non-default Tailscale install, set @@ -98,20 +98,13 @@ verification. Complete these before enabling the public website/release flags: monitors, high contrast, and keyboard navigation. 6. **Signed installer** — verify the installer and installed app use the approved publisher, then install, relaunch, log off/on, uninstall, and reinstall. - Provision signing credentials and monitor SmartScreen reputation. Before - publication, use two unpublished signed versions to prove N to N+1 update, - timestamp/signature validation, tamper rejection, desktop relaunch and brain - recovery, and data preservation. -7. **Public gates** — enable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` to produce - signed test builds. Keep `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0` and the website - flag disabled until the signed installer, signed standalone runtime, and - mandatory two-version update proof pass. The PowerShell installer, Windows - runtime executable, and native archive remain outside release drafts until - the public-release and installed-update-proof gates are both enabled. Only - then may a maintainer enable public release and website flags. - -The cumulative release-proof layer adds the complete Windows signed-release -and publication procedure before this stack can be considered ready. + Provision signing credentials and monitor SmartScreen reputation. Before publication, use two unpublished signed versions to prove N to N+1 update, timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. +7. **Proof, promotion, and public gates** — enable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` to produce the non-publishing signed proof build while `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0` and `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` is unset or `0`. Complete the exact-SHA inventory, including the private signed N to N+1 update and standalone runtime, then bind the approved proof run, manifest digest, and installed-update approval before enabling promotion. Inspect the assembled unpublished draft before publication. Enable the website only after that same release is public and verified. + +The machine-readable scenario inventory and evidence rules are in +[Windows release proof](./windows-release-proof.md). The complete maintainer +procedure is [Windows signed release and publication](../playbooks/windows-signed-release.md), +and installed-host diagnosis is in [Windows support](./windows-support.md). ## Suggested validation commands (from repo root) diff --git a/docs/development/windows-release-proof.md b/docs/development/windows-release-proof.md new file mode 100644 index 000000000..db99b08c6 --- /dev/null +++ b/docs/development/windows-release-proof.md @@ -0,0 +1,258 @@ +# Windows release proof + +Windows public availability is gated by a machine-readable proof bundle for one +exact source commit. A green package job is necessary but not sufficient: the +bundle joins the signed artifacts to clean-host observations and independently +re-hashes every indexed file before a maintainer can mark the commit approved. + +The proof workflow never publishes a GitHub Release and never enables the +website. Keep `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0` and +`VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` unset or `0` while collecting and approving +proof for the initial Windows release. For an already-public Windows channel, +disable the repository publication gate before preparing the next release; +website rollback policy is a separate maintainer decision because taking down +the existing approved download may disrupt users. + +## Inputs and outputs + +Run `.github/workflows/prepare-release.yml` with a version and the lowercase, +40-character commit SHA intended for release. The workflow requires +`ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, refuses to run when public Windows +publication is enabled, checks out the exact SHA, verifies `ci-pass` for it, +builds without publication, and uploads `ade-win-release-v` containing: + +- `ADE--win-x64.exe` +- `ADE--win-x64.exe.blockmap` +- `latest.yml` +- `windows-proof-manifest.json` + +The generated manifest records SHA-256 and byte size for all three release +files. It also records the workflow run, the exact source SHA, the signed-build +validator gates, and SHA-256 digests of the scenario and #999 provenance +indexes. All external scenarios start as `pending`. Before editing the manifest, +record its SHA-256 and the workflow run id; publication uses those values to +retrieve and verify the immutable Actions artifact that supplied the tested +bytes. + +## Proof bundle layout + +Store proof outside the repository. Do not attach raw ADE databases, complete +logs, credential stores, home-directory listings, certificates, or screenshots +that expose names or account details. + +```text +windows-proof-<40-character-sha>/ +|-- windows-proof-manifest.json +|-- artifacts/ +| |-- ADE--win-x64.exe +| |-- ADE--win-x64.exe.blockmap +| `-- latest.yml +`-- evidence/ + |-- 0001-clean-install-win10/ + | |-- gui-first-launch.png + | |-- process-supervisor.json + | `-- log-runtime-start.jsonl + |-- 0002-hkcu-supervisor-recovery/ + | |-- process-after-logon.json + | |-- ipc-initialize.json + | `-- log-recovery.jsonl + `-- ... +``` + +Manifest evidence paths are relative to the `evidence/` directory and use +forward slashes. Numbered scenario directories make the evidence set skimmable; +the manifest remains the canonical index. The validator rejects absolute paths, +backslashes, empty path segments, `..` traversal, unknown manifest fields, and +evidence files over 20 MiB. GUI records accept only PNG, JPEG, or WebP; the +independent machine signals accept only bounded CSV, JSON, JSONL, log, or text +extracts. Raw SQLite databases and archives are therefore not valid evidence. +Every evidence path and content digest must be unique, so copying one extract +into several signal categories cannot satisfy the independence gate. The +validator also scans structured evidence text for obvious credentials and +personal identifiers; visual and semantic redaction still requires review. + +## Exact manifest schema + +`windows-proof-manifest.json` uses `schemaVersion: +"ade.windows-proof/v1"` and has these top-level fields: + +| Field | Contract | +| --- | --- | +| `createdAt` | ISO timestamp for manifest creation. | +| `release` | Repository, version, tag, exact lowercase 40-character `targetSha`, `architecture: "x64"`, and non-publishing workflow identity. | +| `buildValidation` | `signedBuild: true`; canonical artifact validator; Authenticode, RFC3161 timestamp, signer-consistency, and publisher-pin gates all `passed`. | +| `artifacts` | Exactly one `installer`, one `blockmap`, and one `update-manifest`, each with a top-level filename, lowercase SHA-256, and positive byte size. | +| `indexes` | Fixed paths and SHA-256 values for `windows-full-system-scenarios.json` and `windows-source-provenance.json`. | +| `releaseGates` | Signed build enabled; non-publishing workflow true; GitHub Release creation, public Windows release, and website release readiness all false. `websiteReleaseReady` records approval state for this release, not whether a previously approved download is currently visible. | +| `approval` | `proof_pending`, `proof_complete`, or `approved`; publication readiness binds role-based approval to `approvedTargetSha`. | +| `scenarioResults` | Exactly one result for every scenario id: status, redacted host aliases, evidence ids, and optional machine-safe blocker code. | +| `evidence` | Indexed, hashed, redacted evidence records described below. | + +An evidence record is exactly the reviewer-facing reference to one file: + +```json +{ + "id": "proof-0042", + "kind": "ipc", + "collectionMethod": "initialize-probe", + "hostAlias": "win11-lab", + "path": "0002-hkcu-supervisor-recovery/ipc-initialize.json", + "sha256": "<64 lowercase hexadecimal characters>", + "sizeBytes": 1234, + "collectedAt": "2026-08-01T13:00:00.000Z", + "scenarioIds": ["hkcu-supervisor-recovery"], + "redaction": { + "status": "redacted", + "containsSecrets": false, + "containsPersonalIdentifiers": false + } +} +``` + +`hostAlias` is a non-identifying label such as `win10-lab` or `win11-lab`. +Never use a Windows computer name, account name, email, IP address, serial +number, or device id. The validator rejects obvious token forms, email +addresses, IP addresses, and `C:\Users\` paths, but collectors must still +review every file: pattern detection cannot prove that arbitrary screenshots or +free text are safe. A scenario result can link only aliases for the Windows +versions that scenario declares, and every evidence-to-scenario link must have +the matching scenario-to-evidence link. + +## Independent evidence signals + +The complete bundle must contain all six signal kinds, and every scenario must +link the kinds declared in +[`windows-full-system-scenarios.json`](./windows-full-system-scenarios.json). +Each scenario requires at least two independent kinds. + +| Kind | What to retain | What to remove | +| --- | --- | --- | +| `gui` | Cropped screenshot of the state being asserted, with a scenario label added outside the product UI. | Account names, avatars, project paths, repository names, notification content, browser profile details. | +| `log` | Small JSONL extract containing event names, bounded status codes, timestamps, and a random proof-local correlation alias. | Raw log bundles, command arguments, paths, chat text, tokens, URLs, certificate subjects. | +| `db` | Query result containing schema/version, row counts, synthetic row aliases, and convergence values created for the test. | `.db`, WAL, SHM, real project rows, device ids, site ids, secrets. | +| `process` | Process image labels, parent/child relationship, running/stopped state, and proof-local PID aliases. | Full command lines, environment blocks, user names, installation paths, numeric PIDs reused outside the single record. | +| `ipc` | Initialize/status result, channel alias, expected capability flags, and success/denial classification. | Full named-pipe path, user-derived hash inputs, payload text, authentication material. | +| `network` | Route kind, timing, coarse outcome, proof-local correlation alias, and firewall rule classification. | IPs, hostnames, query strings, Relay URLs, pairing credentials, DPoP material, raw packets. | + +GUI evidence is never accepted as the only proof of a runtime claim. A visible +"Connected" state must be paired with network, IPC, DB, process, or log evidence +from the authority that owns the operation. Similarly, a successful log line +does not prove the GUI rendered the user-visible state when a scenario requires +`gui`. + +## Scenario inventory + +[`windows-full-system-scenarios.json`](./windows-full-system-scenarios.json) is +the canonical inventory. Its validator-pinned `acceptanceGates` make the proof +bindings explicit rather than relying on generic shell, peer, or regression +labels. The gates cover PowerShell 5.1, PowerShell 7, cmd, Git Bash, and the full +ConPTY lifecycle; every real provider's authenticated/unauthenticated, +fresh/resume, recovery, and redaction states; standalone CLI/brain and host +lifecycle; OAuth/account directory; every cross-machine direction; transport +streaming and reconnect; signed updater identity/tamper/SmartScreen behavior; +unchanged non-Windows release paths; and the complete draft/website-disabled +contract. Removing a required gate or requirement makes inventory validation +fail. Each gate is also pinned to one exact scenario id, whose +`acceptanceRequirementIds` must enumerate every structured requirement; a +generic replacement scenario or watered-down label cannot claim the gate. + +Scenario dependencies are declarations, not authorization. Account login, +certificate use, release mutation, and public-service changes must be performed +only by an authorized human or workflow. A blocked external dependency remains +`blocked`; it must never be rewritten as `pass` or omitted. + +The inventory uses these gate-to-scenario bindings; the JSON index is the +authority for the complete validator-pinned requirement arrays and pass +conditions: + +| Acceptance gate id | Bound scenario id | Contract boundary | +| --- | --- | --- | +| `shell-conpty-matrix` | `explicit-shell-conpty-matrix` | PowerShell 5.1, PowerShell 7, cmd, Git Bash, and the complete ConPTY lifecycle. | +| `provider-lifecycle-matrix` | `explicit-provider-lifecycle-matrix` | Every real provider in authenticated, unauthenticated, fresh, resume, recovery, and redaction states. | +| `standalone-cli-brain` | `standalone-cli-brain-lifecycle` | Standalone payload install/lifecycle/update, OpenSSH-gated remote bootstrap, and damaged-install recovery. | +| `brain-host-lifecycle` | `brain-host-lifecycle-explicit` | Desktop-closed operation, crash detection/restart, login/logout/reboot, repair, reinstall, and uninstall. | +| `account-oauth-directory` | `account-oauth-directory-explicit` | Default-browser callback, encrypted account state, reauthentication, sign-out, and existing-machine discovery. | +| `cross-machine-directions` | `cross-machine-directions-explicit` | Every required Windows/macOS/Linux/iOS/web session and client/runtime direction. | +| `transport-streaming-reconnect` | `transport-streaming-reconnect-explicit` | LAN/firewall, Tailscale, Relay, reconnect, chat/terminal streams, and remote commands. | +| `signed-updater-proof` | `signed-updater-proof-explicit` | Signed N to N+1 identity/timestamp, tamper rejection, relaunch, brain/data recovery, and SmartScreen observation. | +| `unchanged-release-paths` | `unchanged-release-paths-explicit` | Unchanged macOS desktop/runtime, Linux runtime, web, Relay, and iOS release paths. | +| `draft-assets-and-website` | `draft-assets-website-explicit` | Complete Windows artifact/update metadata and a disabled but correctly targeted website control. | + +## Validation phases + +From the repository root: + +```powershell +node apps/desktop/scripts/windows-proof-manifest.mjs validate-inventory +node apps/desktop/scripts/windows-proof-manifest.mjs validate-provenance + +node apps/desktop/scripts/windows-proof-manifest.mjs validate ` + --manifest \windows-proof-manifest.json ` + --phase build ` + --expected-sha <40-character-sha> ` + --artifact-root \artifacts +``` + +After every pre-tag scenario passes and its evidence is redacted and indexed, set +`approval.state` to `proof_complete` and run: + +```powershell +node apps/desktop/scripts/windows-proof-manifest.mjs validate ` + --manifest \windows-proof-manifest.json ` + --phase complete ` + --expected-sha <40-character-sha> ` + --artifact-root \artifacts ` + --evidence-root \evidence +``` + +The inventory marks `draft-assets-and-website` as `stage: "post-draft"`. +`complete` and `publication-readiness` intentionally allow that one scenario to +remain `pending`, because the immutable candidate must be approved before the +tag workflow can assemble an unpublished draft. Every `pre-tag` gate must pass. + +An authorized Windows release maintainer may then set only these approval +fields: `state: "approved"`, `approvedTargetSha` to the same exact release SHA, +`approverRole: "windows-release-maintainer"`, and `approvedAt`. Do not record a +person's name or account identifier. Run `--phase publication-readiness` with +both roots. This phase still requires `publicReleaseEnabled` and +`websiteReleaseReady` to be false. + +After that validation succeeds, set the protected repository variables +`ADE_WINDOWS_APPROVED_PROOF_SHA`, `ADE_WINDOWS_APPROVED_PROOF_RUN_ID`, and +`ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256` to the exact approved source SHA, +the original non-publishing workflow run id, and the SHA-256 of the original +`proof_pending` build manifest. The public release workflow downloads that +immutable Actions artifact, verifies the manifest digest, run id, tag, exact +source SHA, and all artifact hashes, and promotes those same installer bytes. +It does not rebuild Windows with a new signing timestamp. Enabling publication +and the website remains a separate explicit maintainer action described in the +signed-release playbook. + +After the tag workflow creates the unpublished draft, collect the bounded +draft-asset and disabled-website evidence, set the post-draft scenario to +`pass`, and run `--phase draft-readiness` with both roots. That phase requires +the exact role-based approval and every scenario, including the post-draft gate, +to pass. Do not publish the draft before it succeeds. + +## Source provenance + +[`windows-source-provenance.json`](./windows-source-provenance.json) maps all +nine commits from David Whatley's #999 head to their rebased commits and to the +semantic stack layers that derived from them. Keep this index when repartitioning +or cherry-picking the stack. The validator pins each source-to-rebased pair and +requires every source commit to appear in at least one semantic layer. Every +derived commit must preserve: + +```text +Co-authored-by: David Whatley +Based-on: nsxdavid/ADE#999 +``` + +The manifest stores the provenance index digest so a release proof cannot be +silently detached from the credited source mapping. + +That provenance index also resolves the original #999 Codex inline P2: current +startup is a channel/user-qualified HKCU Run value plus hidden PowerShell +supervisor, the launcher writes the advisory supervisor/runtime PID record, and +an initialized runtime IPC response is the separate readiness record. Any +ONLOGON Scheduled Task is legacy residue to remove, not a current service. diff --git a/docs/development/windows-source-provenance.json b/docs/development/windows-source-provenance.json new file mode 100644 index 000000000..47be42f78 --- /dev/null +++ b/docs/development/windows-source-provenance.json @@ -0,0 +1,136 @@ +{ + "schemaVersion": "ade.windows-source-provenance/v1", + "sourcePullRequest": { + "baseRepository": "arul28/ADE", + "headRepository": "nsxdavid/ADE", + "number": 999, + "url": "https://github.com/arul28/ADE/pull/999", + "authorName": "David Whatley", + "authorLogin": "nsxdavid", + "headSha": "24e47be41ad942f80f423eea9e67bab25218ac0d" + }, + "commitMappings": [ + { + "sourceSha": "9b1ffc367d71b387ba0d49850d37827a1703cfce", + "rebasedSha": "a97f9fc6e9ed0bad68428e24e8ca5126e0d46bf1", + "subject": "feat(desktop): add Windows native build support" + }, + { + "sourceSha": "236330ad9095d6e30f2068572faec5b53ae7c1b2", + "rebasedSha": "cf9e8af77919ee5b78d3b62ccd2aec15c01d2ac8", + "subject": "fix(pairing): use platform-neutral computer copy" + }, + { + "sourceSha": "615eda5ec4a8b81c1e99f02030817dad5880878d", + "rebasedSha": "c3ab7394d275d8fcadbf2fd248cb6f39461fa553", + "subject": "fix(runtime): stabilize Windows dev recovery" + }, + { + "sourceSha": "0eae1517c9b5caa67a6eb5a12ce9ddff5f50392a", + "rebasedSha": "d924e34e05f7acd1bbe210ada735dc2b4027755e", + "subject": "feat(release): add guarded Windows release flow" + }, + { + "sourceSha": "0cfcc1c2c0f9d1c7023c2a64052463647899ca6f", + "rebasedSha": "0ac7ce522fab0cba6737e76f4091ce9f4a97d064", + "subject": "fix(windows): harden release and uninstall flow" + }, + { + "sourceSha": "de52986c188be8b6bc8f3f6de5c486fd53ada436", + "rebasedSha": "7f3fe926bfaf1a786aaa8aa044b0e9b6585b33b4", + "subject": "fix(windows): address release review findings" + }, + { + "sourceSha": "fb3bfe95a9b008006e54ae97b8878e9dbb1c25e5", + "rebasedSha": "2d6d161a7784954769c99b032cfe8a1bbabde9d0", + "subject": "fix(windows): harden packaged runtime lifecycle" + }, + { + "sourceSha": "7cc22ca5273f60857e1c91a6bed885e3123087d4", + "rebasedSha": "06591c12355d5d76a548be3a3ff6178a654910a9", + "subject": "fix(windows): launch desktop test shards through npm CLI" + }, + { + "sourceSha": "24e47be41ad942f80f423eea9e67bab25218ac0d", + "rebasedSha": "fc7764dd4ecf27f2c95218abf1ce0a78488812df", + "subject": "fix(windows): normalize uninstall paths and CI coverage" + } + ], + "stackLayers": [ + { + "id": "windows-runtime-and-ipc", + "purpose": "Per-user brain supervision, named-pipe isolation, process lifecycle, and runtime recovery.", + "sourceCommits": [ + "9b1ffc367d71b387ba0d49850d37827a1703cfce", + "615eda5ec4a8b81c1e99f02030817dad5880878d", + "fb3bfe95a9b008006e54ae97b8878e9dbb1c25e5" + ] + }, + { + "id": "windows-providers-and-clients", + "purpose": "Provider, PTY, App Control, sync, iOS, web, and platform-neutral client behavior.", + "sourceCommits": [ + "9b1ffc367d71b387ba0d49850d37827a1703cfce", + "236330ad9095d6e30f2068572faec5b53ae7c1b2", + "de52986c188be8b6bc8f3f6de5c486fd53ada436", + "24e47be41ad942f80f423eea9e67bab25218ac0d" + ] + }, + { + "id": "windows-packaging-and-ci", + "purpose": "NSIS packaging, native resources, uninstall ownership, artifact smoke, and Windows CI.", + "sourceCommits": [ + "9b1ffc367d71b387ba0d49850d37827a1703cfce", + "0cfcc1c2c0f9d1c7023c2a64052463647899ca6f", + "de52986c188be8b6bc8f3f6de5c486fd53ada436", + "fb3bfe95a9b008006e54ae97b8878e9dbb1c25e5", + "7cc22ca5273f60857e1c91a6bed885e3123087d4", + "24e47be41ad942f80f423eea9e67bab25218ac0d" + ] + }, + { + "id": "windows-release-and-update", + "purpose": "Fail-closed Authenticode, non-publishing preparation, updater authority, and guarded publication.", + "sourceCommits": [ + "9b1ffc367d71b387ba0d49850d37827a1703cfce", + "0eae1517c9b5caa67a6eb5a12ce9ddff5f50392a", + "0cfcc1c2c0f9d1c7023c2a64052463647899ca6f", + "de52986c188be8b6bc8f3f6de5c486fd53ada436" + ] + }, + { + "id": "windows-proof-and-support", + "purpose": "Exact-SHA proof, full-system scenarios, troubleshooting, support boundaries, and source attribution.", + "sourceCommits": [ + "9b1ffc367d71b387ba0d49850d37827a1703cfce", + "236330ad9095d6e30f2068572faec5b53ae7c1b2", + "615eda5ec4a8b81c1e99f02030817dad5880878d", + "0eae1517c9b5caa67a6eb5a12ce9ddff5f50392a", + "0cfcc1c2c0f9d1c7023c2a64052463647899ca6f", + "de52986c188be8b6bc8f3f6de5c486fd53ada436", + "fb3bfe95a9b008006e54ae97b8878e9dbb1c25e5", + "7cc22ca5273f60857e1c91a6bed885e3123087d4", + "24e47be41ad942f80f423eea9e67bab25218ac0d" + ] + } + ], + "sourceReviewDispositions": [ + { + "id": "codex-p2-windows-supervisor-registration", + "source": "original-999-codex-inline-p2", + "status": "resolved", + "finding": "The source documentation described the current Windows brain as an ONLOGON Scheduled Task.", + "disposition": "Current behavior is documented as a per-user and per-channel HKCU Run value that starts a hidden PowerShell supervisor. The launcher writes an advisory supervisor/runtime PID record, and readiness is proven separately by an initialized runtime IPC response. Scheduled Tasks are legacy cleanup only.", + "currentRegistration": "hkcu-run", + "launcher": "hidden-powershell-supervisor", + "pidRecord": "supervisor-runtime-pids", + "readinessRecord": "initialized-runtime-ipc", + "scheduledTasks": "legacy-cleanup-only", + "stackLayers": ["windows-runtime-and-ipc", "windows-proof-and-support"] + } + ], + "requiredCommitTrailers": { + "coAuthor": "David Whatley ", + "basedOn": "nsxdavid/ADE#999" + } +} diff --git a/docs/development/windows-support.md b/docs/development/windows-support.md new file mode 100644 index 000000000..3546f32c7 --- /dev/null +++ b/docs/development/windows-support.md @@ -0,0 +1,203 @@ +# Windows support and troubleshooting + +ADE supports the packaged Windows 10/11 x64 desktop preview. Windows ARM64, +Windows as an SSH-bootstrap runtime target, native Windows OS computer use, and +iOS Simulator remain out of scope. App Control over CDP, the built-in Browser, +proof-file ingestion, phone pairing, and the local Windows brain are supported +within the preview boundary. + +Public Windows installers remain disabled until the exact-SHA release proof in +[`windows-release-proof.md`](./windows-release-proof.md) passes. + +## Background brain on Windows + +The current Windows supervisor is **not a Scheduled Task**. `ade brain start` +installs a per-user, per-channel `REG_SZ` value under: + +```text +HKCU\Software\Microsoft\Windows\CurrentVersion\Run +``` + +The value starts a hidden, UTF-8-with-BOM PowerShell launcher under the +channel's ADE home. That launcher restores the complete resolved brain +environment, starts the packaged ADE runtime, and writes an advisory JSON record +containing supervisor and runtime process ids. Status validates that the +recorded supervisor is a PowerShell process whose command line names the exact +launcher before treating it as ADE-owned. + +Release proof records these as separate bounded signals, not one inferred +"service is running" claim: + +- a redacted process record that the channel-qualified HKCU Run value exists; +- a redacted process record derived from `.pid.json`, with proof-local + aliases for both `supervisorPid` and `runtimePid` rather than numeric PIDs; +- a redacted IPC readiness record showing that a client completed initialize on + the channel's runtime endpoint after startup or recovery; and +- a bounded startup/recovery log extract correlated by a proof-local alias. + +The PID JSON is advisory ownership evidence. It is not a readiness file, and an +ONLOGON Scheduled Task is not a current supervisor or readiness mechanism. +The Run entry re-establishes the supervisor at the next user logon; the launcher +waits for the runtime and then exits with it. For an in-session runtime crash, +prove crash detection and recovery through the supported `ade brain start` or +repair path rather than claiming that the launcher contains an automatic +restart loop. + +Stable, Beta, Alpha, and custom service labels get separate value names, +launchers, ADE homes, and named pipes. Installation and uninstall also query and +remove exact Scheduled Task names created by older preview builds. A Scheduled +Task found during migration is legacy residue; it is not the active supervisor +contract. + +The current-user startup key requires no administrator access. Do not move the +entry to HKLM, request elevation merely to start ADE at login, or tell users to +repair the current service in Task Scheduler. + +## First diagnostic pass + +Run these from a newly opened PowerShell or cmd window so it sees the current +user PATH: + +```powershell +ade brain status --text +ade doctor --json +ade runtime service-status --text +``` + +Before sharing output, remove user paths, repository names, machine/device ids, +account details, IPs, URLs with query strings, and any credential-shaped value. +Prefer reporting the status/error code and ADE version. Do not attach the whole +ADE home or a raw database. + +## Brain is installed but not running + +1. Confirm the channel-qualified value exists under the current user's Run key. + Do not paste its command because it contains local installation paths. +2. Run `ade brain start`, then `ade brain status --text`. +3. Confirm the PID record exists under the channel ADE home's `runtime/` + directory and that the supervisor/runtime are live. Report proof-local PID + aliases rather than raw command lines. +4. If status says the startup entry exists but the supervisor is not running, + run `ade brain stop` followed by `ade brain start` to rewrite the launcher and + entry. +5. If an old `ADE Runtime` Scheduled Task remains, normal install/uninstall + should remove it. A cleanup failure is fail-closed; capture only its bounded + error text and retry from the same user account. + +If a logoff or reboot was part of the failure, verify both process recovery and +an initialized client call. A process in Task Manager alone does not prove that +the named pipe is healthy. + +## Desktop cannot attach to the local brain + +- Confirm desktop and CLI are the same channel. Stable and Beta intentionally + use different ADE homes and pipe names. +- Close only the affected channel, restart its brain, then relaunch that channel. +- A second Windows account must not be able to use the first account's pipe. + Access denial is expected isolation, not a reason to weaken named-pipe ACLs. +- Do not publish a full named-pipe path in support artifacts. Record a channel + alias and the initialize result. +- If the desktop reports a packaged/runtime build mismatch, reinstall the same + signed version before changing local state. + +## Phone, Relay, or Tailscale cannot connect + +- `crdtSyncAvailable` must be true. If it is false, reinstall/restart the + packaged app; Windows pairing is blocked rather than silently running without + CRR support. +- LAN requires the approved Windows Defender Firewall path and a reachable sync + listener. Record the route kind and coarse outcome, never IPs or pairing data. +- Tailscale resolution checks the normal Program Files installation, then PATH. + Use `ADE_TAILSCALE_CLI` only for a non-default install. +- Relay is available only while the Windows brain has a current ADE account + lease and owns the machine-wide sync-host lease. There is no separate Relay + toggle. +- A connected Relay control is not sufficient; directory publication requires + the end-to-end self-probe to pass. +- On a physical iPhone, verify both the visible connection and a bidirectional + synthetic CRR row roundtrip. Do not treat "Connected" as complete sync proof. + +## Provider or terminal launch fails + +- Reproduce as four separately labelled cases: Windows PowerShell 5.1, + PowerShell 7, cmd, and Git Bash. A generic `powershell` result does not prove + either PowerShell version, and cmd results do not stand in for Git Bash. +- For each of Claude, Codex, Cursor, OpenCode, and Droid, test authenticated and + unauthenticated states, fresh launch, and tracked resume separately. Resume + bugs often bypass the fresh structured command path. +- Use a disposable path/prompt containing Unicode, spaces, quotes, dollar and + percent signs, ampersands, and backticks. +- Exercise ConPTY resize, Ctrl+C, explicit cancellation, descendant cleanup, + and crash restore. Do not use a successful terminal close as the only + process-cleanup signal. +- Confirm interrupted sessions expose bounded recovery metadata and actionable + recovery instructions. Redact prompts, transcripts, command arguments, + account identifiers, and paths independently for every provider. +- Provider credentials are machine-local. Never copy a credential store into an + evidence bundle or across a cross-machine handoff. + +## Standalone brain or installation is damaged + +- Exercise the standalone `ade-win32-x64` install independently of the desktop: + `ade brain start`, `ade brain status --text`, `ade doctor --json`, and the + supported update command must all operate from the standalone payload. +- A Windows client may bootstrap supported macOS/Linux runtimes. Verify the + OpenSSH client prerequisite first and require a bounded actionable error when + it is absent. This does not make Windows an SSH-bootstrap *target*. +- At this source revision, `ade brain update` still rejects a Windows runtime + target and installed-desktop discovery in `ade doctor` is incomplete. Treat + either result as a public-release proof blocker; do not relabel it as a pass + or substitute a manual binary copy. +- Repair a partial launcher/runtime installation with the product's bounded + repair path before testing reinstall or uninstall. Repair must preserve + projects and user state; reinstall and uninstall remain separate scenarios + with their own ownership checks. + +## Update does not land + +- Confirm `latest.yml`, installer, and blockmap match the manifest hashes and + the installed app's update authority is the repository that built it. +- Preserve the first verified cached download after a lost handoff; ADE clears + the cache after a second failure for the same target. +- A checksum, signature, publisher-pin, or timestamp failure must clear unsafe + cache data and must not replace the running app. +- Verify the relaunch version, project data, and HKCU supervisor after update. + References to "Scheduled Task repair" are stale; only legacy-task cleanup is + expected. + +## Uninstall leaves residue + +The uninstaller owns only its channel's background service, launcher/PID record, +terminal shim, and the matching user PATH entry. It should not delete projects, +another ADE channel, or unrelated PATH entries. + +Check, using redacted aliases: + +- the channel's HKCU Run value is absent; +- its supervisor/runtime process tree is stopped; +- its launcher and PID record are absent; +- exact legacy Scheduled Task names are absent; +- the installer-owned shim and PATH component are absent; and +- other channels and project data remain. + +If cleanup fails, leave the product state intact and report the bounded cleanup +error. Do not manually delete broad ADE homes as a support shortcut. + +## Support bundle boundary + +A safe support bundle may contain the proof manifest, the relevant scenario +entries, small redacted event extracts, synthetic DB query results, proof-local +process/IPC/network summaries, and cropped screenshots. It must not contain: + +- `.ade/secrets`, credential stores, tokens, pairing PINs, DPoP material, or + signing files; +- raw `.db`, WAL, or SHM files; +- full logs or environment blocks; +- home paths, account or computer names, email addresses, IPs, or device ids; +- chat transcripts, source files, private repository names, or artifact bytes + unrelated to the reproduction; or +- raw certificate subjects or certificate files. Store only approved digests + needed to compare identity. + +Validate release evidence with `windows-proof-manifest.mjs` before attaching it +to a support or release record. diff --git a/docs/playbooks/windows-signed-release.md b/docs/playbooks/windows-signed-release.md index 1fb0cdaf7..e7b3dbf32 100644 --- a/docs/playbooks/windows-signed-release.md +++ b/docs/playbooks/windows-signed-release.md @@ -7,7 +7,8 @@ ADE already publishes macOS and standalone Mac/Linux files through GitHub Releas After setup, a normal release is: 1. A maintainer tags an approved commit. -2. GitHub Actions builds, signs, and checks every platform. +2. GitHub Actions builds and checks the other platforms, then retrieves and + verifies the immutable Windows artifact approved by the exact-SHA proof. 3. GitHub Actions creates one unpublished GitHub Release. 4. A maintainer checks it and makes it public. @@ -22,10 +23,12 @@ Complete these actions in order: 1. Configure the Windows signing credentials in GitHub Actions. 2. Enable signed test builds while keeping Windows publication off. 3. Build a signed test version in GitHub Actions. -4. Test the installer on clean Windows 10 and Windows 11 computers. -5. Enable Windows in the production release workflow. -6. Tag the approved commit. GitHub Actions builds every platform and creates one unpublished release. -7. Check the release, make it public, and enable the Windows website link. +4. Complete the exact-SHA proof inventory on clean Windows 10 and Windows 11 computers, including a signed N to N+1 private update. +5. Validate and approve the redacted proof bundle while publication and the website remain disabled. +6. Bind the approved proof SHA and enable Windows in the production release workflow. +7. Tag the approved commit. GitHub Actions builds the other platforms, promotes + the exact approved Windows artifact, and creates one unpublished release. +8. Check the release, make it public, and only then enable the Windows website link. The sections below provide the commands and pass conditions. @@ -94,12 +97,38 @@ The run passes only when: - Required bundled tools and runtime files are present. - The macOS and standalone runtime jobs still pass. - No GitHub Release is created. +- A machine-readable proof manifest is generated for the exact checked-out SHA. Download the `ade-win-release-v` artifact from the successful run. It must contain exactly: - `ADE--win-x64.exe` - `ADE--win-x64.exe.blockmap` - `latest.yml` +- `windows-proof-manifest.json` + +Before editing the manifest, record the immutable build identity: + +```powershell +$PROOF_RUN_ID = "" +$BUILD_MANIFEST_SHA256 = (Get-FileHash ` + -LiteralPath \windows-proof-manifest.json ` + -Algorithm SHA256).Hash.ToLowerInvariant() +``` + +Create the proof-bundle layout from the proof contract: leave the manifest at +the bundle root and place the installer, blockmap, and `latest.yml` under its +`artifacts/` directory before running the validator. + +Run the build-phase validator after download. It re-hashes all three release +files and rejects a manifest for any other commit: + +```powershell +node apps/desktop/scripts/windows-proof-manifest.mjs validate ` + --manifest \windows-proof-manifest.json ` + --phase build ` + --expected-sha $RELEASE_SHA ` + --artifact-root \artifacts +``` ### 3. Test the signed installer @@ -115,12 +144,47 @@ On clean Windows 10 x64 and Windows 11 x64 computers: 2. Install ADE and run the same check on `%LOCALAPPDATA%\Programs\ADE\ADE.exe`. 3. Confirm both results report `Status: Valid` and the approved ADE publisher. -4. Test installation, launch, projects, lanes, agent sessions, terminals, `ade doctor`, the background service, iPhone pairing, uninstall, and reinstall. -5. Record the commit, workflow run, Windows versions, publisher, certificate fingerprint, file SHA-256, and results. +4. Test installation, launch, projects, lanes, agent sessions, terminals, `ade doctor`, the HKCU startup supervisor, iPhone pairing, uninstall, and reinstall. +5. Complete every scenario in the machine-readable inventory, including + Windows 10/11, PowerShell 5.1, PowerShell 7, cmd, Git Bash, every provider state, account OAuth/directory, + cross-machine clients, LAN/Tailscale/Relay, the private signed N to N+1 + updater path, and regressions. +6. Index separately redacted GUI, log, DB, process, IPC, and network evidence. + Record publisher/certificate identity as a digest, never raw certificate + material or personal/account identifiers. + +Use the [Windows release proof contract](../development/windows-release-proof.md) +and its [full-system scenario inventory](../development/windows-full-system-scenarios.json) +for the acceptance test. Installed-host recovery guidance lives in +[Windows support](../development/windows-support.md). + +### 4. Approve the exact-SHA proof + +After every `pre-tag` result is `pass`, evidence is redacted, and the manifest +is `proof_complete`, run the complete validator with the artifact and evidence +roots. Leave `draft-assets-website-explicit` pending until the unpublished draft +exists in Publish step 3. An authorized Windows release maintainer then changes the role-only +approval fields as described in the proof contract and runs +`--phase publication-readiness`. This validation still requires both public +release and website flags to be false. + +Set the protected proof binding to the manifest's exact SHA, the original +non-publishing run, and the original build-manifest digest: + +```bash +gh variable set ADE_WINDOWS_APPROVED_PROOF_SHA \ + --repo arul28/ADE --body "$RELEASE_SHA" +gh variable set ADE_WINDOWS_APPROVED_PROOF_RUN_ID \ + --repo arul28/ADE --body "$PROOF_RUN_ID" +gh variable set ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256 \ + --repo arul28/ADE --body "$BUILD_MANIFEST_SHA256" +``` -Use the [full Windows test matrix](../development/windows-port-lane.md#external-proof-gates-before-public-availability) for the acceptance test. +The public workflow fails closed if any binding is absent or malformed, if the +SHA or release tag differs, or if the retrieved immutable artifact's manifest +or release-file hashes differ. -### 4. Enable Windows releases +### 5. Enable Windows releases After the recorded test results pass: @@ -130,6 +194,8 @@ gh variable set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \ ``` This setting allows the existing release workflow to add validated Windows files to its combined draft. It does not publish a release by itself. +Keep `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` unset or `0`; website readiness is not +publication approval. ## Publish a release @@ -143,8 +209,18 @@ Confirm: - The normal `ci-pass` check succeeded for that exact commit. - The version tag does not already exist. - The signed-build and public-release settings are `1`. - -For later releases, run the non-publishing workflow for the approved version and commit. For the first Windows release, reuse the Step 2 result if its version and commit are unchanged. +- `ADE_WINDOWS_APPROVED_PROOF_SHA` equals the approved 40-character commit SHA. +- `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` identifies the approved non-publishing run. +- `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256` equals the recorded original + build-manifest digest. +- The proof manifest passed `publication-readiness` with public release and + website gates still disabled. + +For later releases, first set `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0`, then run +the non-publishing workflow and collect fresh exact-SHA proof for the approved +version and commit. Restore the public gate only after that proof is approved. +For the first Windows release, reuse the Step 2 result if its version and commit +are unchanged. ```bash VERSION="" @@ -167,7 +243,10 @@ git tag -a "v$VERSION" "$RELEASE_SHA" -m "ADE v$VERSION" git push origin "v$VERSION" ``` -The tag starts `.github/workflows/release.yml`. GitHub Actions builds every platform and creates or updates an unpublished GitHub Release. +The tag starts `.github/workflows/release.yml`. GitHub Actions rebuilds the +other platforms, promotes the exact approved Windows artifact from the +non-publishing run, verifies its immutable identity and hashes, and creates or +updates an unpublished GitHub Release. Do not rerun the release workflow after the release is public. The workflow refuses to overwrite assets on a published release. @@ -187,6 +266,22 @@ Require: - The Windows installer, its `.blockmap`, and `latest.yml` are present. - The downloaded Windows installer and installed `ADE.exe` have valid signatures from the approved publisher. - The installed app points to `arul28/ADE` for updates. +- The approved proof SHA equals the release target and the downloaded Windows + files match the proof manifest hashes. + +Add the redacted draft-asset and disabled-website evidence to the proof bundle, +set `draft-assets-website-explicit` to `pass`, then run the final gate: + +```powershell +node apps/desktop/scripts/windows-proof-manifest.mjs validate ` + --manifest \windows-proof-manifest.json ` + --phase draft-readiness ` + --expected-sha $RELEASE_SHA ` + --expected-tag "v$VERSION" ` + --expected-run-id $PROOF_RUN_ID ` + --artifact-root \artifacts ` + --evidence-root \evidence +``` Stop if any file or check is wrong. Do not upload replacement files by hand and do not move an existing tag. Fix the source, choose a higher version, and repeat the automated process. @@ -213,7 +308,11 @@ For the first public Windows release: Later releases use the same website link and do not require another setting change. -After a second signed Windows release exists, verify that an installed older version updates to it. This validates the updater; it is not required infrastructure for publishing the first Windows release. +The N to N+1 updater path must already have been proven with two signed, +non-public builds before the first public Windows release. Repeat it against +public metadata after the next public signed release as an ongoing regression +check; do not weaken the initial proof requirement merely because public N+1 +does not exist yet. ## If something fails @@ -242,6 +341,9 @@ WinGet, the Microsoft Store, MSIX, and enterprise deployment can be added later. - [Tag-triggered release workflow](../../.github/workflows/release.yml) - [Shared release jobs](../../.github/workflows/release-core.yml) - [Contract tests](../../apps/desktop/scripts/windows-release-contract.test.mjs) +- [Proof manifest generator and validator](../../apps/desktop/scripts/windows-proof-manifest.mjs) +- [Exact-SHA Windows proof](../development/windows-release-proof.md) +- [Windows support and troubleshooting](../development/windows-support.md) - [Update behavior](../features/onboarding-and-settings/desktop-auto-update.md) ## References From 4da126b5fc75b9e2cc7402a0b0cc352c04f80dd3 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 22:02:32 -0400 Subject: [PATCH 03/27] fix(windows): bind standalone runtime proof Require the exact standalone Windows runtime, native archive, installer, and checksum manifest in immutable release evidence. Record both original review dispositions and make shell coverage explicit. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- .github/workflows/release-core.yml | 34 ++++++++ WINDOWS_PORT.md | 11 ++- .../desktop/scripts/windows-proof-indexes.mjs | 87 +++++++++++++------ .../scripts/windows-proof-manifest.mjs | 84 +++++++++++++----- .../scripts/windows-proof-manifest.test.mjs | 61 ++++++++++++- .../scripts/windows-release-contract.test.mjs | 8 ++ docs/ARCHITECTURE.md | 2 +- .../windows-full-system-scenarios.json | 42 ++++----- docs/development/windows-port-lane.md | 2 +- docs/development/windows-release-proof.md | 40 ++++++--- .../windows-source-provenance.json | 11 +++ docs/development/windows-support.md | 7 +- docs/playbooks/windows-signed-release.md | 19 ++-- 13 files changed, 305 insertions(+), 103 deletions(-) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index a7e769f86..c23428101 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -370,6 +370,32 @@ jobs: if ($installers.Count -ne 1) { throw "Expected exactly one signed Windows installer, found $($installers.Count)." } & apps/desktop/scripts/windows-installed-product-smoke.ps1 -InstallerPath $installers[0].FullName + - name: Stage standalone runtime proof assets + shell: pwsh + run: | + $releaseDir = "apps/desktop/release" + Copy-Item -LiteralPath "apps/ade-cli/scripts/install-runtime.sh" -Destination "$releaseDir/install.sh" + Copy-Item -LiteralPath "apps/ade-cli/scripts/install-runtime.ps1" -Destination "$releaseDir/install.ps1" + Get-ChildItem -LiteralPath "apps/desktop/resources/runtime" -File -Filter "ade-*" | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $releaseDir + } + Get-Item -LiteralPath "$releaseDir/ade-win32-x64.exe" | Out-Null + Get-Item -LiteralPath "$releaseDir/ade-win32-x64.native.tar.gz" | Out-Null + $checksumFiles = @( + Get-Item -LiteralPath "$releaseDir/install.sh" + Get-Item -LiteralPath "$releaseDir/install.ps1" + Get-ChildItem -LiteralPath $releaseDir -File -Filter "ade-*" + ) | Sort-Object Name + $checksumLines = $checksumFiles | ForEach-Object { + $digest = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$digest $($_.Name)" + } + [System.IO.File]::WriteAllLines( + (Join-Path (Resolve-Path $releaseDir) "SHA256SUMS"), + $checksumLines, + [System.Text.UTF8Encoding]::new($false) + ) + - name: Generate exact-SHA Windows proof manifest shell: pwsh run: >- @@ -402,6 +428,10 @@ jobs: path: | apps/desktop/release/*.exe apps/desktop/release/*.exe.blockmap + apps/desktop/release/ade-* + apps/desktop/release/install.sh + apps/desktop/release/install.ps1 + apps/desktop/release/SHA256SUMS apps/desktop/release/latest.yml apps/desktop/release/windows-proof-manifest.json if-no-files-found: error @@ -456,6 +486,10 @@ jobs: path: | apps/desktop/release/*.exe apps/desktop/release/*.exe.blockmap + apps/desktop/release/ade-* + apps/desktop/release/install.sh + apps/desktop/release/install.ps1 + apps/desktop/release/SHA256SUMS apps/desktop/release/latest.yml apps/desktop/release/windows-proof-manifest.json if-no-files-found: error diff --git a/WINDOWS_PORT.md b/WINDOWS_PORT.md index 03ed04c6e..00aa04f1e 100644 --- a/WINDOWS_PORT.md +++ b/WINDOWS_PORT.md @@ -104,12 +104,11 @@ and full-system acceptance inventory live in installed-host diagnosis is in [`docs/development/windows-support.md`](docs/development/windows-support.md). -Four source follow-ups are explicitly outside this preview boundary: -`ade brain update` continues to reject Windows because Windows as a standalone -remote brain is deferred; `ade doctor` does not yet discover an installed -Windows desktop version. None is required to install and run the local Windows -desktop preview, but each should be resolved before calling Windows a -first-class remote-brain/operations platform. +Windows standalone install and `ade brain update` are implemented. Windows is +still not an SSH-bootstrap target, and public readiness requires exact-SHA +evidence for the standalone executable, native archive, `install.ps1`, and +`SHA256SUMS` from the immutable proof run. Missing or mismatched evidence blocks +release; a manual binary copy is not acceptable proof. ## Current readiness diff --git a/apps/desktop/scripts/windows-proof-indexes.mjs b/apps/desktop/scripts/windows-proof-indexes.mjs index 9b2de5e5f..6ccf15e27 100644 --- a/apps/desktop/scripts/windows-proof-indexes.mjs +++ b/apps/desktop/scripts/windows-proof-indexes.mjs @@ -162,7 +162,7 @@ export function validateInventory(inventory) { const dimensions = inventory.dimensions; const requiredDimensions = { operatingSystems: ["windows-10-22h2-x64", "windows-11-x64"], - shells: ["powershell", "cmd"], + shells: ["powershell-5-1", "powershell-7", "cmd", "git-bash"], providers: ["claude", "codex", "cursor", "droid", "opencode"], clients: ["windows-desktop", "ade-code", "hosted-web", "ios", "desktop-peer"], routes: ["lan", "tailscale", "relay"], @@ -179,6 +179,13 @@ export function validateInventory(inventory) { addError(errors, `dimensions.${name}`, `must include ${JSON.stringify(requiredValue)}`); } } + if (name === "shells") { + for (const value of values) { + if (!required.includes(value)) { + addError(errors, "dimensions.shells", `contains unknown shell ${JSON.stringify(value)}`); + } + } + } validateDimensionCoverage(errors, inventory, name, values); } } @@ -428,18 +435,13 @@ export function validateProvenance(provenance) { addError(errors, "stackLayers", `source commit ${sourceSha} is not attributed to any layer`); } } - const dispositions = provenance.sourceReviewDispositions; - if (!Array.isArray(dispositions) || dispositions.length !== 1) { - addError(errors, "sourceReviewDispositions", "must contain the original #999 Codex P2 disposition"); - } else { - const disposition = dispositions[0]; - if (requireObject(errors, disposition, "sourceReviewDispositions[0]")) { - rejectUnknownKeys(errors, disposition, "sourceReviewDispositions[0]", [ + const dispositionSpecs = new Map([ + ["codex-p2-windows-supervisor-registration", { + allowedKeys: [ "id", "source", "status", "finding", "disposition", "currentRegistration", "launcher", "pidRecord", "readinessRecord", "scheduledTasks", "stackLayers", - ]); - const exactFields = { - id: "codex-p2-windows-supervisor-registration", + ], + exactFields: { source: "original-999-codex-inline-p2", status: "resolved", currentRegistration: "hkcu-run", @@ -447,24 +449,57 @@ export function validateProvenance(provenance) { pidRecord: "supervisor-runtime-pids", readinessRecord: "initialized-runtime-ipc", scheduledTasks: "legacy-cleanup-only", - }; - for (const [field, expected] of Object.entries(exactFields)) { - if (disposition[field] !== expected) { - addError(errors, `sourceReviewDispositions[0].${field}`, `must equal ${expected}`); - } - } - requireString(errors, disposition.finding, "sourceReviewDispositions[0].finding"); - requireString(errors, disposition.disposition, "sourceReviewDispositions[0].disposition"); - if (requireStringArray(errors, disposition.stackLayers, "sourceReviewDispositions[0].stackLayers", { - pattern: SAFE_ID_PATTERN, - })) { - for (const requiredLayer of ["windows-runtime-and-ipc", "windows-proof-and-support"]) { - if (!disposition.stackLayers.includes(requiredLayer)) { - addError(errors, "sourceReviewDispositions[0].stackLayers", `must include ${requiredLayer}`); - } + }, + stackLayers: ["windows-runtime-and-ipc", "windows-proof-and-support"], + }], + ["codex-p2-windows-desktop-app-channel", { + allowedKeys: [ + "id", "source", "status", "finding", "disposition", "requestedAppName", + "currentExecutableMatch", "fallbackPolicy", "stackLayers", + ], + exactFields: { + source: "original-999-codex-inline-p2", + status: "resolved", + requestedAppName: "channel-qualified-executable", + currentExecutableMatch: "exact-request-only", + fallbackPolicy: "search-requested-channel", + }, + stackLayers: ["windows-providers-and-clients", "windows-proof-and-support"], + }], + ]); + const dispositions = provenance.sourceReviewDispositions; + if (!Array.isArray(dispositions) || dispositions.length !== dispositionSpecs.size) { + addError(errors, "sourceReviewDispositions", "must contain both original #999 Codex P2 dispositions"); + } + const dispositionIds = new Set(); + if (Array.isArray(dispositions)) dispositions.forEach((disposition, index) => { + const field = `sourceReviewDispositions[${index}]`; + if (!requireObject(errors, disposition, field)) return; + const spec = dispositionSpecs.get(disposition.id); + if (!spec) { + addError(errors, `${field}.id`, "is not a reviewed original #999 Codex P2 disposition"); + return; + } + if (dispositionIds.has(disposition.id)) addError(errors, `${field}.id`, "must be unique"); + dispositionIds.add(disposition.id); + rejectUnknownKeys(errors, disposition, field, spec.allowedKeys); + for (const [name, expected] of Object.entries(spec.exactFields)) { + if (disposition[name] !== expected) addError(errors, `${field}.${name}`, `must equal ${expected}`); + } + requireString(errors, disposition.finding, `${field}.finding`); + requireString(errors, disposition.disposition, `${field}.disposition`); + if (requireStringArray(errors, disposition.stackLayers, `${field}.stackLayers`, { + pattern: SAFE_ID_PATTERN, + })) { + for (const requiredLayer of spec.stackLayers) { + if (!disposition.stackLayers.includes(requiredLayer)) { + addError(errors, `${field}.stackLayers`, `must include ${requiredLayer}`); } } } + }); + for (const id of dispositionSpecs.keys()) { + if (!dispositionIds.has(id)) addError(errors, "sourceReviewDispositions", `is missing ${id}`); } if (requireObject(errors, provenance.requiredCommitTrailers, "requiredCommitTrailers")) { rejectUnknownKeys(errors, provenance.requiredCommitTrailers, "requiredCommitTrailers", ["coAuthor", "basedOn"]); diff --git a/apps/desktop/scripts/windows-proof-manifest.mjs b/apps/desktop/scripts/windows-proof-manifest.mjs index 64cbbd113..b3c0ec6e0 100644 --- a/apps/desktop/scripts/windows-proof-manifest.mjs +++ b/apps/desktop/scripts/windows-proof-manifest.mjs @@ -198,22 +198,24 @@ function validateNoSensitiveData(value, errors, field = "manifest") { } } -function findReleaseArtifacts(releaseDir) { - const entries = fs.readdirSync(releaseDir, { withFileTypes: true }) - .filter((entry) => entry.isFile()) - .map((entry) => entry.name); - const installers = entries.filter((name) => name.endsWith(".exe") && !name.endsWith(".exe.blockmap")); - const blockmaps = entries.filter((name) => name.endsWith(".exe.blockmap")); - const latest = entries.filter((name) => name === "latest.yml"); - if (installers.length !== 1 || blockmaps.length !== 1 || latest.length !== 1) { - throw new Error("Expected exactly one Windows installer, one installer blockmap, and latest.yml."); - } +function expectedReleaseArtifacts(version) { return [ - ["installer", installers[0]], - ["blockmap", blockmaps[0]], - ["update-manifest", latest[0]], - ].map(([role, file]) => { + ["installer", `ADE-${version}-win-x64.exe`], + ["blockmap", `ADE-${version}-win-x64.exe.blockmap`], + ["update-manifest", "latest.yml"], + ["standalone-runtime", "ade-win32-x64.exe"], + ["standalone-native-archive", "ade-win32-x64.native.tar.gz"], + ["standalone-installer", "install.ps1"], + ["runtime-checksums", "SHA256SUMS"], + ]; +} + +function findReleaseArtifacts(releaseDir, version) { + return expectedReleaseArtifacts(version).map(([role, file]) => { const filePath = path.join(releaseDir, file); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + throw new Error(`Expected release artifact ${file}.`); + } return { role, file, @@ -283,7 +285,7 @@ export function createBuildManifest({ signerConsistency: "passed", publisherPin: "passed", }, - artifacts: findReleaseArtifacts(releaseDir), + artifacts: findReleaseArtifacts(releaseDir, releaseMatch[1]), indexes: { scenarioInventory: { path: "docs/development/windows-full-system-scenarios.json", @@ -319,8 +321,8 @@ export function createBuildManifest({ } function validateArtifactEntries(errors, artifacts, version) { - if (!Array.isArray(artifacts) || artifacts.length !== 3) { - addError(errors, "artifacts", "must contain exactly installer, blockmap, and update-manifest entries"); + if (!Array.isArray(artifacts) || artifacts.length !== 7) { + addError(errors, "artifacts", "must contain exactly the desktop and standalone Windows release entries"); return; } const roles = new Set(); @@ -335,15 +337,11 @@ function validateArtifactEntries(errors, artifacts, version) { requireString(errors, artifact.sha256, `${field}.sha256`, SHA256_PATTERN); requirePositiveInteger(errors, artifact.sizeBytes, `${field}.sizeBytes`); }); - for (const role of ["installer", "blockmap", "update-manifest"]) { + for (const [role] of expectedReleaseArtifacts(version)) { if (!roles.has(role)) addError(errors, "artifacts", `is missing role ${role}`); } if (typeof version === "string" && version.length > 0) { - const expectedFiles = { - installer: `ADE-${version}-win-x64.exe`, - blockmap: `ADE-${version}-win-x64.exe.blockmap`, - "update-manifest": "latest.yml", - }; + const expectedFiles = Object.fromEntries(expectedReleaseArtifacts(version)); for (const artifact of artifacts) { if (expectedFiles[artifact.role] && artifact.file !== expectedFiles[artifact.role]) { addError(errors, `artifacts.${artifact.role}.file`, `must equal ${expectedFiles[artifact.role]}`); @@ -352,6 +350,45 @@ function validateArtifactEntries(errors, artifacts, version) { } } +function validateRuntimeChecksums(errors, artifactRoot, artifacts) { + if (!artifactRoot || !Array.isArray(artifacts)) return; + const byRole = new Map(artifacts.map((artifact) => [artifact?.role, artifact])); + const checksumArtifact = byRole.get("runtime-checksums"); + if (!checksumArtifact || !isSafeRelativePath(checksumArtifact.file) || checksumArtifact.file.includes("/")) return; + const checksumPath = path.join(path.resolve(artifactRoot), checksumArtifact.file); + if (!fs.existsSync(checksumPath) || !fs.statSync(checksumPath).isFile()) return; + + const listed = new Map(); + for (const [index, line] of fs.readFileSync(checksumPath, "utf8").split(/\r?\n/).entries()) { + if (line.length === 0) continue; + const match = line.match(/^([0-9a-f]{64}) [ *](.+)$/); + if (!match) { + addError(errors, `artifacts.runtime-checksums.line${index + 1}`, "must use lowercase SHA-256 checksum format"); + continue; + } + const [, digest, file] = match; + if (!isSafeRelativePath(file) || file.includes("/")) { + addError(errors, `artifacts.runtime-checksums.line${index + 1}`, "must name a safe top-level relative file"); + continue; + } + if (listed.has(file)) { + addError(errors, "artifacts.runtime-checksums", `contains duplicate entry ${file}`); + continue; + } + listed.set(file, digest); + } + + for (const role of ["standalone-runtime", "standalone-native-archive", "standalone-installer"]) { + const artifact = byRole.get(role); + if (!artifact || typeof artifact.file !== "string" || typeof artifact.sha256 !== "string") continue; + if (!listed.has(artifact.file)) { + addError(errors, "artifacts.runtime-checksums", `is missing ${artifact.file}`); + } else if (listed.get(artifact.file) !== artifact.sha256) { + addError(errors, "artifacts.runtime-checksums", `does not bind the declared SHA-256 for ${artifact.file}`); + } + } +} + function verifyIndexedFile(errors, root, entry, field, { scanText = false } = {}) { if (!root || !entry || !isSafeRelativePath(entry.path)) return; const absoluteRoot = path.resolve(root); @@ -468,6 +505,7 @@ export function validateManifest(manifest, { manifest.artifacts.forEach((artifact, index) => { verifyIndexedFile(errors, artifactRoot, { ...artifact, path: artifact.file }, `artifacts[${index}]`); }); + validateRuntimeChecksums(errors, artifactRoot, manifest.artifacts); } else { addError(errors, "artifactRoot", "is required so release files are independently re-hashed"); } diff --git a/apps/desktop/scripts/windows-proof-manifest.test.mjs b/apps/desktop/scripts/windows-proof-manifest.test.mjs index d9d9f8284..339e34b41 100644 --- a/apps/desktop/scripts/windows-proof-manifest.test.mjs +++ b/apps/desktop/scripts/windows-proof-manifest.test.mjs @@ -34,6 +34,13 @@ function createFixture() { fs.writeFileSync(path.join(releaseDir, "ADE-1.2.3-win-x64.exe"), "signed installer fixture"); fs.writeFileSync(path.join(releaseDir, "ADE-1.2.3-win-x64.exe.blockmap"), "blockmap fixture"); fs.writeFileSync(path.join(releaseDir, "latest.yml"), "version: 1.2.3\n"); + fs.writeFileSync(path.join(releaseDir, "ade-win32-x64.exe"), "standalone runtime fixture"); + fs.writeFileSync(path.join(releaseDir, "ade-win32-x64.native.tar.gz"), "standalone native archive fixture"); + fs.writeFileSync(path.join(releaseDir, "install.ps1"), "Write-Output 'install fixture'\n"); + const checksummedFiles = ["ade-win32-x64.exe", "ade-win32-x64.native.tar.gz", "install.ps1"]; + fs.writeFileSync(path.join(releaseDir, "SHA256SUMS"), checksummedFiles + .map((file) => `${sha256File(path.join(releaseDir, file))} ${file}`) + .join("\n") + "\n"); const manifest = createBuildManifest({ releaseDir, targetSha, @@ -101,6 +108,10 @@ function completeManifest(manifest, evidenceRoot) { test("committed Windows scenario inventory covers the full declared matrix", () => { assert.deepEqual(validateInventory(inventory), []); + assert.deepEqual(inventory.dimensions.shells, ["powershell-5-1", "powershell-7", "cmd", "git-bash"]); + const genericShell = clone(inventory); + genericShell.dimensions.shells[0] = "powershell"; + assert.ok(validateInventory(genericShell).some((error) => error.includes("unknown shell"))); const incomplete = clone(inventory); incomplete.acceptanceGates.find((gate) => gate.id === "shell-conpty-matrix").requirements.pop(); assert.ok(validateInventory(incomplete).some((error) => error.includes("shell-conpty-matrix") || error.includes("crash-restore"))); @@ -117,7 +128,14 @@ test("committed provenance maps every #999 source commit into stack layers", () assert.deepEqual(validateProvenance(provenance), []); assert.equal(provenance.sourcePullRequest.authorName, "David Whatley"); assert.equal(provenance.commitMappings.length, 9); - assert.equal(provenance.sourceReviewDispositions[0].status, "resolved"); + assert.deepEqual(new Set(provenance.sourceReviewDispositions.map(({ id }) => id)), new Set([ + "codex-p2-windows-desktop-app-channel", + "codex-p2-windows-supervisor-registration", + ])); + + const omittedDisposition = clone(provenance); + omittedDisposition.sourceReviewDispositions.pop(); + assert.ok(validateProvenance(omittedDisposition).some((error) => error.includes("both original #999 Codex P2 dispositions"))); const changedMapping = clone(provenance); changedMapping.commitMappings[0].rebasedSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -141,6 +159,15 @@ test("build manifest is exact-SHA, non-publishing, and artifact-bound", (t) => { assert.equal(fixture.manifest.release.workflow.publish, false); assert.equal(fixture.manifest.releaseGates.publicReleaseEnabled, false); assert.equal(fixture.manifest.releaseGates.websiteReleaseReady, false); + assert.deepEqual(fixture.manifest.artifacts.map(({ role }) => role), [ + "installer", + "blockmap", + "update-manifest", + "standalone-runtime", + "standalone-native-archive", + "standalone-installer", + "runtime-checksums", + ]); const wrongBuildIdentity = validateManifest(fixture.manifest, { inventory, @@ -183,6 +210,31 @@ test("build validation rejects a different approved SHA or changed artifact", (t assert.ok(errors.some((error) => error.includes("artifacts[0].sha256"))); }); +test("build validation rejects a checksum manifest that does not bind standalone runtime bytes", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + const checksumPath = path.join(fixture.releaseDir, "SHA256SUMS"); + const changedChecksums = fs.readFileSync(checksumPath, "utf8").replace( + /^[0-9a-f]{64}( ade-win32-x64\.exe)$/m, + `${"a".repeat(64)}$1`, + ); + fs.writeFileSync(checksumPath, changedChecksums); + const checksumArtifact = fixture.manifest.artifacts.find(({ role }) => role === "runtime-checksums"); + checksumArtifact.sha256 = sha256File(checksumPath); + checksumArtifact.sizeBytes = fs.statSync(checksumPath).size; + + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("does not bind the declared SHA-256 for ade-win32-x64.exe"))); +}); + test("complete validation re-hashes indexed evidence and enforces independent signals", (t) => { const fixture = createFixture(); t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); @@ -279,7 +331,10 @@ test("malformed evidence links return validation errors instead of throwing", (t malformedInventory.scenarios[0].hosts = [7]; malformedInventory.dimensions.operatingSystems = {}; const malformedProvenance = clone(provenance); - malformedProvenance.sourceReviewDispositions[0].disposition = 7; + const malformedDispositionIndex = malformedProvenance.sourceReviewDispositions.findIndex( + ({ id }) => id === "codex-p2-windows-supervisor-registration", + ); + malformedProvenance.sourceReviewDispositions[malformedDispositionIndex].disposition = 7; assert.doesNotThrow(() => validateManifest(fixture.manifest, { inventory: malformedInventory, @@ -307,7 +362,7 @@ test("malformed evidence links return validation errors instead of throwing", (t assert.ok(errors.some((error) => error.includes("scenarioResults[1].hostAliases"))); assert.ok(errors.some((error) => error.includes("scenarios[0].hosts[0]"))); assert.ok(errors.some((error) => error.includes("dimensions.operatingSystems"))); - assert.ok(errors.some((error) => error.includes("sourceReviewDispositions[0].disposition"))); + assert.ok(errors.some((error) => error.includes(`sourceReviewDispositions[${malformedDispositionIndex}].disposition`))); }); test("manifest rejects unsafe evidence paths and obvious identifiers", (t) => { diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index 024eeebde..25184d0e9 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -408,6 +408,11 @@ test("signed Windows proof workflow is non-publishing and emits an exact-SHA man assert.match(windowsRelease, /--expected-sha "\$\{\{ inputs\.target_ref \}\}"/); assert.match(windowsRelease, /--expected-tag "\$\{\{ inputs\.release_tag \}\}"/); assert.match(windowsRelease, /--expected-run-id "\$\{\{ github\.run_id \}\}"/); + assert.match(windowsRelease, /name: Stage standalone runtime proof assets/); + assert.match(windowsRelease, /ade-win32-x64\.exe/); + assert.match(windowsRelease, /ade-win32-x64\.native\.tar\.gz/); + assert.match(windowsRelease, /install-runtime\.ps1/); + assert.match(windowsRelease, /SHA256SUMS/); assert.match(windowsRelease, /apps\/desktop\/release\/windows-proof-manifest\.json/); }); @@ -419,6 +424,9 @@ test("public Windows release promotes the approved immutable proof artifact", () assert.match(windowsRelease, /Get-FileHash -LiteralPath \$manifestPath -Algorithm SHA256/); assert.match(windowsRelease, /APPROVED_BUILD_MANIFEST_SHA256/); assert.match(windowsRelease, /--expected-run-id "\$\{\{ vars\.ADE_WINDOWS_APPROVED_PROOF_RUN_ID \}\}"/); + assert.match(windowsRelease, /apps\/desktop\/release\/ade-\*/); + assert.match(windowsRelease, /apps\/desktop\/release\/install\.ps1/); + assert.match(windowsRelease, /apps\/desktop\/release\/SHA256SUMS/); assert.match(windowsRelease, /inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f8be46bd3..a250040ea 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1515,7 +1515,7 @@ Windows: - Electron-builder generates `resources/app-update.yml` from the GitHub publish configuration. The source default remains the upstream `arul28/ADE`; CI sets `ADE_RELEASE_REPOSITORY=${{ github.repository }}` so fork package validation proves the installed updater authority matches the repository that built it without changing upstream release behavior. - `release-core.yml` runs the signed Windows job only when repository variable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`. If enabled, missing signing credentials or pinned publisher identity fail both the desktop and standalone-runtime signing paths and block publication; if disabled, the skipped Windows job does not block the macOS release. The installer, updater metadata, `install.ps1`, signed `ade-win32-x64.exe`, and its native archive are added to the draft only when both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`. Keep both publication gates off until clean-host install checks and the mandatory two-unpublished-version N-to-N+1 signed update proof pass; that proof includes timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. - `release-core.yml` keeps Windows proof building and publication promotion as separate jobs. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, the non-publishing proof job requires signing credentials and a pinned publisher identity for the desktop and standalone runtime. A normal publishing workflow skips Windows cleanly while `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is off. Once the public and installed-update-proof gates are on, promotion retrieves the immutable approved proof-run artifact instead of signing or rebuilding a second set. If signed builds are disabled, the skipped Windows jobs do not block the macOS release. -- The non-publishing `prepare-release.yml` path emits `windows-proof-manifest.json` for the exact 40-character source SHA and hashes the installer, blockmap, updater manifest, standalone executable/native archive/installer/checksums, full-system scenario inventory, and #999 provenance index. Public Windows publication additionally requires the protected exact SHA, proof run id, and original build-manifest digest. Promotion validates those values plus the release tag and file hashes before draft assembly; the website download flag remains a separate disabled-by-default gate. See `docs/development/windows-release-proof.md`. +- The non-publishing `prepare-release.yml` path requires signed Windows builds while public publication is off. It emits `windows-proof-manifest.json` for the exact 40-character source SHA and hashes the installer, blockmap, updater manifest, standalone executable, native archive, `install.ps1`, `SHA256SUMS`, full-system scenario inventory, and #999 provenance index. It verifies that the checksum manifest binds the standalone files. Public Windows publication additionally requires the protected exact SHA, proof run id, original build-manifest digest, and installed-update approval. Promotion validates those values plus the release tag and file hashes before draft assembly; the website download flag remains a separate disabled-by-default gate. See `docs/development/windows-release-proof.md`. - Ongoing Windows integration lane (rebase with `main`, smoke tests, backlog): `docs/development/windows-port-lane.md`. Post-packaging hardening (`apps/desktop/scripts/`): diff --git a/docs/development/windows-full-system-scenarios.json b/docs/development/windows-full-system-scenarios.json index 0bdcda65c..312b957b6 100644 --- a/docs/development/windows-full-system-scenarios.json +++ b/docs/development/windows-full-system-scenarios.json @@ -6,8 +6,10 @@ "windows-11-x64" ], "shells": [ - "powershell", - "cmd" + "powershell-5-1", + "powershell-7", + "cmd", + "git-bash" ], "providers": [ "claude", @@ -140,7 +142,7 @@ "hosts": ["windows-10-22h2-x64", "windows-11-x64"], "coverage": { "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], - "shells": ["powershell"], + "shells": ["powershell-5-1", "powershell-7"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], @@ -179,7 +181,7 @@ "hosts": ["windows-11-x64"], "coverage": { "operatingSystems": ["windows-11-x64"], - "shells": ["powershell"], + "shells": ["powershell-5-1", "powershell-7"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], @@ -198,7 +200,7 @@ "hosts": ["windows-10-22h2-x64", "windows-11-x64"], "coverage": { "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], - "shells": ["powershell"], + "shells": ["powershell-5-1", "powershell-7"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], @@ -218,7 +220,7 @@ "hosts": ["windows-10-22h2-x64", "windows-11-x64"], "coverage": { "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], - "shells": ["powershell"], + "shells": ["powershell-5-1", "powershell-7"], "providers": ["claude", "codex", "cursor", "droid", "opencode"], "clients": ["windows-desktop", "ade-code"], "routes": [], @@ -317,7 +319,7 @@ "hosts": ["windows-11-x64"], "coverage": { "operatingSystems": ["windows-11-x64"], - "shells": ["powershell", "cmd"], + "shells": ["powershell-5-1", "powershell-7", "cmd"], "providers": ["codex"], "clients": ["windows-desktop", "ade-code"], "routes": ["tailscale"], @@ -455,7 +457,7 @@ "hosts": ["windows-10-22h2-x64", "windows-11-x64"], "coverage": { "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], - "shells": ["powershell", "cmd"], + "shells": ["powershell-5-1", "powershell-7", "cmd"], "providers": [], "clients": ["windows-desktop"], "routes": [], @@ -475,7 +477,7 @@ "hosts": ["windows-10-22h2-x64", "windows-11-x64"], "coverage": { "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], - "shells": ["powershell", "cmd"], + "shells": ["powershell-5-1", "powershell-7", "cmd"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], @@ -495,7 +497,7 @@ "hosts": ["windows-11-x64"], "coverage": { "operatingSystems": ["windows-11-x64"], - "shells": ["powershell"], + "shells": ["powershell-5-1", "powershell-7"], "providers": ["codex"], "clients": ["windows-desktop"], "routes": ["lan"], @@ -515,7 +517,7 @@ "hosts": ["windows-10-22h2-x64", "windows-11-x64"], "coverage": { "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], - "shells": ["powershell", "cmd"], + "shells": ["powershell-5-1", "powershell-7", "cmd"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], @@ -555,7 +557,7 @@ "hosts": ["windows-10-22h2-x64", "windows-11-x64"], "coverage": { "operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], - "shells": ["powershell"], + "shells": ["powershell-5-1", "powershell-7"], "providers": [], "clients": ["windows-desktop"], "routes": [], @@ -593,7 +595,7 @@ "id": "explicit-shell-conpty-matrix", "title": "PowerShell 5.1, PowerShell 7, cmd, and Git Bash ConPTY matrix", "hosts": ["windows-10-22h2-x64", "windows-11-x64"], - "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell", "cmd"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell-5-1", "powershell-7", "cmd", "git-bash"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, "acceptanceGateIds": ["shell-conpty-matrix"], "acceptanceRequirementIds": ["powershell-5-1", "powershell-7", "cmd", "git-bash", "conpty-unicode", "conpty-metacharacters", "conpty-resize", "conpty-ctrl-c", "conpty-cancel", "descendant-cleanup", "crash-restore"], "requiredEvidenceKinds": ["gui", "process", "log"], @@ -608,7 +610,7 @@ "id": "explicit-provider-lifecycle-matrix", "title": "Real provider authentication and recovery matrix", "hosts": ["windows-10-22h2-x64", "windows-11-x64"], - "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell", "cmd"], "providers": ["claude", "codex", "cursor", "droid", "opencode"], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell-5-1", "powershell-7", "cmd"], "providers": ["claude", "codex", "cursor", "droid", "opencode"], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, "acceptanceGateIds": ["provider-lifecycle-matrix"], "acceptanceRequirementIds": ["claude-authenticated", "claude-unauthenticated", "claude-fresh", "claude-resume", "claude-recovery-metadata", "claude-recovery-instructions", "claude-redaction", "codex-authenticated", "codex-unauthenticated", "codex-fresh", "codex-resume", "codex-recovery-metadata", "codex-recovery-instructions", "codex-redaction", "cursor-authenticated", "cursor-unauthenticated", "cursor-fresh", "cursor-resume", "cursor-recovery-metadata", "cursor-recovery-instructions", "cursor-redaction", "opencode-authenticated", "opencode-unauthenticated", "opencode-fresh", "opencode-resume", "opencode-recovery-metadata", "opencode-recovery-instructions", "opencode-redaction", "droid-authenticated", "droid-unauthenticated", "droid-fresh", "droid-resume", "droid-recovery-metadata", "droid-recovery-instructions", "droid-redaction"], "requiredEvidenceKinds": ["gui", "log", "process", "db"], @@ -623,22 +625,22 @@ "id": "standalone-cli-brain-lifecycle", "title": "Standalone ade-win32-x64 CLI and brain lifecycle", "hosts": ["windows-10-22h2-x64", "windows-11-x64"], - "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell", "cmd"], "providers": [], "clients": ["ade-code"], "routes": ["lan", "tailscale"], "journeys": ["windows-regressions"]}, + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell-5-1", "powershell-7", "cmd"], "providers": [], "clients": ["ade-code"], "routes": ["lan", "tailscale"], "journeys": ["windows-regressions"]}, "acceptanceGateIds": ["standalone-cli-brain"], "acceptanceRequirementIds": ["ade-win32-x64", "install", "start", "status", "doctor", "update", "remote-bootstrap", "openssh-prerequisite", "damaged-install-recovery"], "requiredEvidenceKinds": ["process", "ipc", "log", "network"], "passConditions": [ - "After the standalone ade-win32-x64 implementation exists, its payload installs and proves brain start, status, doctor, and update without the desktop process.", + "The standalone ade-win32-x64 payload installs and proves brain start, status, doctor, and update without the desktop process.", "Remote bootstrap proves the OpenSSH prerequisite and an actionable missing-OpenSSH failure before a successful supported macOS/Linux bootstrap.", "A damaged or partial standalone install produces bounded recovery guidance and recovers without deleting project data." ], - "dependencies": ["BLOCKING IMPLEMENTATION: ADE does not yet build ade-win32-x64 and brain update currently rejects Windows", "Supported macOS and Linux bootstrap hosts", "Disposable damaged-install snapshot"] + "dependencies": ["BLOCKING EVIDENCE: exact-SHA standalone runtime, native archive, install.ps1, and SHA256SUMS must be captured from the immutable proof run", "Supported macOS and Linux bootstrap hosts", "Disposable damaged-install snapshot"] }, { "id": "brain-host-lifecycle-explicit", "title": "Closed desktop, brain crash, login, logout, reboot, repair, reinstall, and uninstall", "hosts": ["windows-10-22h2-x64", "windows-11-x64"], - "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell-5-1", "powershell-7"], "providers": [], "clients": ["windows-desktop", "ade-code"], "routes": [], "journeys": ["windows-regressions"]}, "acceptanceGateIds": ["brain-host-lifecycle"], "acceptanceRequirementIds": ["desktop-closed", "brain-crash", "brain-restart", "login", "logout", "reboot", "repair", "reinstall", "uninstall"], "requiredEvidenceKinds": ["process", "ipc", "log", "db"], @@ -683,7 +685,7 @@ "id": "transport-streaming-reconnect-explicit", "title": "LAN firewall, Tailscale, Relay, reconnect, streaming, and remote-command matrix", "hosts": ["windows-10-22h2-x64", "windows-11-x64"], - "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell", "cmd"], "providers": ["codex"], "clients": ["windows-desktop", "ade-code", "hosted-web", "ios", "desktop-peer"], "routes": ["lan", "tailscale", "relay"], "journeys": ["windows-regressions"]}, + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell-5-1", "powershell-7", "cmd"], "providers": ["codex"], "clients": ["windows-desktop", "ade-code", "hosted-web", "ios", "desktop-peer"], "routes": ["lan", "tailscale", "relay"], "journeys": ["windows-regressions"]}, "acceptanceGateIds": ["transport-streaming-reconnect"], "acceptanceRequirementIds": ["lan-firewall", "tailscale", "relay", "reconnect", "terminal-streaming", "chat-streaming", "remote-commands"], "requiredEvidenceKinds": ["network", "ipc", "gui", "log"], @@ -698,7 +700,7 @@ "id": "signed-updater-proof-explicit", "title": "Signed N to N+1 identity, tamper, relaunch, recovery, preservation, and SmartScreen", "hosts": ["windows-10-22h2-x64", "windows-11-x64"], - "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell"], "providers": [], "clients": ["windows-desktop"], "routes": ["lan"], "journeys": ["signed-n-to-n-plus-one-update"]}, + "coverage": {"operatingSystems": ["windows-10-22h2-x64", "windows-11-x64"], "shells": ["powershell-5-1", "powershell-7"], "providers": [], "clients": ["windows-desktop"], "routes": ["lan"], "journeys": ["signed-n-to-n-plus-one-update"]}, "acceptanceGateIds": ["signed-updater-proof"], "acceptanceRequirementIds": ["signed-n-to-n-plus-one", "rfc3161-timestamp", "publisher-identity", "tamper-rejection", "relaunch", "brain-recovery", "data-preservation", "smartscreen-observation"], "requiredEvidenceKinds": ["gui", "log", "process", "network", "db"], diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index 7da6d1243..81903817c 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -69,7 +69,7 @@ Recent `main` work that is **not** inherently macOS-only but can surface path/sh Windows Graphics Capture/UI Automation is not implemented. - **iOS Simulator / Attention Notch** — hidden on Windows by capability. These remain macOS-only product surfaces. -- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, `release-core.yml` fails the Windows jobs unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match `WINDOWS_SIGNING_EXPECTED_SUBJECT` or `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`; the installer and packaged app must also share one certificate. That test job cannot block macOS publication while public Windows publication is disabled. SmartScreen reputation remains a release-engineering concern, not only app code. +- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, the proof workflow fails unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match the pinned subject or thumbprint; the installer and packaged app must also share one certificate. Public readiness still requires immutable exact-SHA proof for the Windows runtime, native archive, installer, and checksum manifest. Disabled Windows publication does not block the macOS release path. SmartScreen reputation remains a release-engineering concern, not only app code. - **Docs in `AGENTS.md`** still emphasize macOS Codex/Computer Use; Windows developers should use this file + `docs/ARCHITECTURE.md` for WSL/VM dev notes if applicable. ## External proof gates before public availability diff --git a/docs/development/windows-release-proof.md b/docs/development/windows-release-proof.md index db99b08c6..7794f5a13 100644 --- a/docs/development/windows-release-proof.md +++ b/docs/development/windows-release-proof.md @@ -24,15 +24,20 @@ builds without publication, and uploads `ade-win-release-v` containing: - `ADE--win-x64.exe` - `ADE--win-x64.exe.blockmap` - `latest.yml` +- `ade-win32-x64.exe` +- `ade-win32-x64.native.tar.gz` +- `install.ps1` +- `SHA256SUMS` - `windows-proof-manifest.json` -The generated manifest records SHA-256 and byte size for all three release -files. It also records the workflow run, the exact source SHA, the signed-build -validator gates, and SHA-256 digests of the scenario and #999 provenance -indexes. All external scenarios start as `pending`. Before editing the manifest, -record its SHA-256 and the workflow run id; publication uses those values to -retrieve and verify the immutable Actions artifact that supplied the tested -bytes. +The generated manifest records SHA-256 and byte size for all seven indexed +release files. It also verifies that `SHA256SUMS` binds the standalone Windows +runtime, its native archive, and `install.ps1`; records the workflow run, exact +source SHA, signed-build validator gates, and SHA-256 digests of the scenario +and #999 provenance indexes. All external scenarios start as `pending`. Before +editing the manifest, record its SHA-256 and the workflow run id; publication +uses those values to retrieve and verify the immutable Actions artifact that +supplied the tested bytes. ## Proof bundle layout @@ -46,7 +51,11 @@ windows-proof-<40-character-sha>/ |-- artifacts/ | |-- ADE--win-x64.exe | |-- ADE--win-x64.exe.blockmap -| `-- latest.yml +| |-- latest.yml +| |-- ade-win32-x64.exe +| |-- ade-win32-x64.native.tar.gz +| |-- install.ps1 +| `-- SHA256SUMS `-- evidence/ |-- 0001-clean-install-win10/ | |-- gui-first-launch.png @@ -81,7 +90,7 @@ personal identifiers; visual and semantic redaction still requires review. | `createdAt` | ISO timestamp for manifest creation. | | `release` | Repository, version, tag, exact lowercase 40-character `targetSha`, `architecture: "x64"`, and non-publishing workflow identity. | | `buildValidation` | `signedBuild: true`; canonical artifact validator; Authenticode, RFC3161 timestamp, signer-consistency, and publisher-pin gates all `passed`. | -| `artifacts` | Exactly one `installer`, one `blockmap`, and one `update-manifest`, each with a top-level filename, lowercase SHA-256, and positive byte size. | +| `artifacts` | Exactly one each of `installer`, `blockmap`, `update-manifest`, `standalone-runtime`, `standalone-native-archive`, `standalone-installer`, and `runtime-checksums`, with fixed top-level filenames, lowercase SHA-256, and positive byte size. The checksum manifest must bind the other three standalone Windows entries. | | `indexes` | Fixed paths and SHA-256 values for `windows-full-system-scenarios.json` and `windows-source-provenance.json`. | | `releaseGates` | Signed build enabled; non-publishing workflow true; GitHub Release creation, public Windows release, and website release readiness all false. `websiteReleaseReady` records approval state for this release, not whether a previously approved download is currently visible. | | `approval` | `proof_pending`, `proof_complete`, or `approved`; publication readiness binds role-based approval to `approvedTargetSha`. | @@ -251,8 +260,11 @@ Based-on: nsxdavid/ADE#999 The manifest stores the provenance index digest so a release proof cannot be silently detached from the credited source mapping. -That provenance index also resolves the original #999 Codex inline P2: current -startup is a channel/user-qualified HKCU Run value plus hidden PowerShell -supervisor, the launcher writes the advisory supervisor/runtime PID record, and -an initialized runtime IPC response is the separate readiness record. Any -ONLOGON Scheduled Task is legacy residue to remove, not a current service. +That provenance index also resolves both original #999 Codex inline P2s. The +desktop launcher reuses its current executable only when its basename exactly +matches the requested channel-qualified executable; otherwise it searches the +requested app/channel candidates. Current startup is a channel/user-qualified +HKCU Run value plus hidden PowerShell supervisor, the launcher writes the +advisory supervisor/runtime PID record, and an initialized runtime IPC response +is the separate readiness record. Any ONLOGON Scheduled Task is legacy residue +to remove, not a current service. diff --git a/docs/development/windows-source-provenance.json b/docs/development/windows-source-provenance.json index 47be42f78..952293f09 100644 --- a/docs/development/windows-source-provenance.json +++ b/docs/development/windows-source-provenance.json @@ -115,6 +115,17 @@ } ], "sourceReviewDispositions": [ + { + "id": "codex-p2-windows-desktop-app-channel", + "source": "original-999-codex-inline-p2", + "status": "resolved", + "finding": "The source fallback could reuse the current Stable ADE.exe when a different requested app or channel, such as ADE Beta, was missing.", + "disposition": "The Windows launcher reuses the current executable only when its basename exactly matches the requested channel-qualified executable. Otherwise it searches candidates for the requested app or channel and fails closed when none exists. This behavior is owned by the Windows providers and clients layer from PR2.", + "requestedAppName": "channel-qualified-executable", + "currentExecutableMatch": "exact-request-only", + "fallbackPolicy": "search-requested-channel", + "stackLayers": ["windows-providers-and-clients", "windows-proof-and-support"] + }, { "id": "codex-p2-windows-supervisor-registration", "source": "original-999-codex-inline-p2", diff --git a/docs/development/windows-support.md b/docs/development/windows-support.md index 3546f32c7..fb1924919 100644 --- a/docs/development/windows-support.md +++ b/docs/development/windows-support.md @@ -144,9 +144,10 @@ the named pipe is healthy. - A Windows client may bootstrap supported macOS/Linux runtimes. Verify the OpenSSH client prerequisite first and require a bounded actionable error when it is absent. This does not make Windows an SSH-bootstrap *target*. -- At this source revision, `ade brain update` still rejects a Windows runtime - target and installed-desktop discovery in `ade doctor` is incomplete. Treat - either result as a public-release proof blocker; do not relabel it as a pass +- Windows standalone install and `ade brain update` are implemented, but the + exact-SHA proof must re-hash `ade-win32-x64.exe`, its native archive, + `install.ps1`, and `SHA256SUMS` from the immutable proof run. Missing or + mismatched evidence is a public-release blocker; do not relabel it as a pass or substitute a manual binary copy. - Repair a partial launcher/runtime installation with the product's bounded repair path before testing reinstall or uninstall. Repair must preserve diff --git a/docs/playbooks/windows-signed-release.md b/docs/playbooks/windows-signed-release.md index e7b3dbf32..5a6c5397f 100644 --- a/docs/playbooks/windows-signed-release.md +++ b/docs/playbooks/windows-signed-release.md @@ -2,7 +2,7 @@ Use this guide after the Windows release changes have been merged into `main`. -ADE already publishes macOS and standalone Mac/Linux files through GitHub Releases. The Windows release uses that same pipeline. +ADE already publishes macOS and standalone runtime files through GitHub Releases. The Windows release uses that same pipeline and includes the standalone Windows payload. After setup, a normal release is: @@ -99,11 +99,16 @@ The run passes only when: - No GitHub Release is created. - A machine-readable proof manifest is generated for the exact checked-out SHA. -Download the `ade-win-release-v` artifact from the successful run. It must contain exactly: +Download the `ade-win-release-v` artifact from the successful run. Its +manifest-indexed files must include: - `ADE--win-x64.exe` - `ADE--win-x64.exe.blockmap` - `latest.yml` +- `ade-win32-x64.exe` +- `ade-win32-x64.native.tar.gz` +- `install.ps1` +- `SHA256SUMS` - `windows-proof-manifest.json` Before editing the manifest, record the immutable build identity: @@ -116,11 +121,13 @@ $BUILD_MANIFEST_SHA256 = (Get-FileHash ` ``` Create the proof-bundle layout from the proof contract: leave the manifest at -the bundle root and place the installer, blockmap, and `latest.yml` under its -`artifacts/` directory before running the validator. +the bundle root and place all seven manifest-indexed files under its +`artifacts/` directory before running the validator. Keep any additional +standalone platform files from the Actions artifact unchanged alongside them. -Run the build-phase validator after download. It re-hashes all three release -files and rejects a manifest for any other commit: +Run the build-phase validator after download. It re-hashes all seven indexed +release files, verifies the standalone entries against `SHA256SUMS`, and +rejects a manifest for any other commit: ```powershell node apps/desktop/scripts/windows-proof-manifest.mjs validate ` From 98d7d653aa869cfc913ca6e466152ef0914d8d18 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 22:31:43 -0400 Subject: [PATCH 04/27] fix(windows): promote immutable runtime proof Publish the exact approved Windows proof-run runtime set, select the desktop installer explicitly, and require installed-update approval before promotion. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- .github/workflows/release-core.yml | 51 ++++++++----------- .../scripts/windows-release-contract.test.mjs | 27 +++++----- 2 files changed, 34 insertions(+), 44 deletions(-) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index c23428101..374e894ff 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -437,7 +437,7 @@ jobs: if-no-files-found: error promote-approved-win-proof: - if: ${{ inputs.publish && vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + if: ${{ inputs.publish && vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' }} needs: verify runs-on: windows-latest concurrency: @@ -732,22 +732,11 @@ jobs: merge-multiple: true - name: Add standalone runtime installer - env: - BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} - PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} - WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} run: | cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh chmod 755 release-assets/runtime/install.sh - if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then - cp apps/ade-cli/scripts/install-runtime.ps1 release-assets/runtime/install.ps1 - fi - name: Generate standalone runtime checksums - env: - BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} - PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} - WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} run: | set -euo pipefail runtime_assets=( @@ -761,9 +750,6 @@ jobs: ade-linux-x64 ade-linux-x64.native.tar.gz ) - if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then - runtime_assets+=(install.ps1 ade-win32-x64.exe ade-win32-x64.native.tar.gz) - fi (cd release-assets/runtime && sha256sum "${runtime_assets[@]}" | LC_ALL=C sort -k2 > SHA256SUMS) - name: Validate publish asset manifest @@ -822,8 +808,8 @@ jobs: run: | set -euo pipefail shopt -s nullglob - installers=(release-assets/win/*.exe) - blockmaps=(release-assets/win/*.exe.blockmap) + installers=(release-assets/win/ADE-*-win-x64.exe) + blockmaps=(release-assets/win/ADE-*-win-x64.exe.blockmap) if [ "${#installers[@]}" -ne 1 ] || [ "${#blockmaps[@]}" -ne 1 ]; then echo "::error::Expected exactly one signed Windows installer and blockmap." exit 1 @@ -831,12 +817,12 @@ jobs: test -s "${installers[0]}" test -s "${blockmaps[0]}" test -s release-assets/win/latest.yml - test -s release-assets/runtime/install.ps1 - test -s release-assets/runtime/ade-win32-x64.exe - test -s release-assets/runtime/ade-win32-x64.native.tar.gz - grep -Eq '[[:space:]]install\.ps1$' release-assets/runtime/SHA256SUMS - grep -Eq '[[:space:]]ade-win32-x64\.exe$' release-assets/runtime/SHA256SUMS - grep -Eq '[[:space:]]ade-win32-x64\.native\.tar\.gz$' release-assets/runtime/SHA256SUMS + test -s release-assets/win/install.sh + test -s release-assets/win/install.ps1 + test -s release-assets/win/ade-win32-x64.exe + test -s release-assets/win/ade-win32-x64.native.tar.gz + test -s release-assets/win/SHA256SUMS + (cd release-assets/win && sha256sum -c SHA256SUMS) - name: Create or update draft GitHub release env: GH_TOKEN: ${{ github.token }} @@ -852,26 +838,31 @@ jobs: # consumes; DMGs are the human downloads. Mac blockmaps stay omitted. # Only an exact-SHA-approved Windows proof allows the public gate to # add the signed installer, blockmap, and latest.yml as one set. - files=( + base_files=( release-assets/mac/*.dmg release-assets/mac/*.zip release-assets/mac/latest-mac.yml + ) + runtime_files=( release-assets/runtime/install.sh release-assets/runtime/SHA256SUMS release-assets/runtime/ade-darwin-* release-assets/runtime/ade-linux-* ) + files=("${base_files[@]}" "${runtime_files[@]}") # This repository variable stays disabled until the signed installer # passes the clean-host release checks. if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then - files+=( - release-assets/win/*.exe - release-assets/win/*.exe.blockmap + files=( + "${base_files[@]}" + release-assets/win/ADE-*-win-x64.exe + release-assets/win/ADE-*-win-x64.exe.blockmap release-assets/win/latest.yml - release-assets/runtime/install.ps1 - release-assets/runtime/ade-win32-x64.exe - release-assets/runtime/ade-win32-x64.native.tar.gz + release-assets/win/install.sh + release-assets/win/install.ps1 + release-assets/win/SHA256SUMS + release-assets/win/ade-* ) fi diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index 25184d0e9..c90ad7101 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -275,17 +275,16 @@ test("standalone Windows release assets remain behind every publication and proo path.join(repoRoot, "apps", "ade-cli", "scripts", "install-runtime.ps1"), "utf8", ); - const releaseFiles = publish.slice(publish.indexOf("files=("), publish.indexOf("if [ \"$BUILD_WINDOWS\"", publish.indexOf("files=("))); + const releaseFiles = publish.slice(publish.indexOf("base_files=("), publish.indexOf("if [ \"$BUILD_WINDOWS\"", publish.indexOf("base_files=("))); assert.doesNotMatch(releaseFiles, /install\.ps1|ade-win32-x64/); - assert.match(publish, /runtime_assets\+=\(install\.ps1 ade-win32-x64\.exe ade-win32-x64\.native\.tar\.gz\)/); - assert.match(publish, /test -s release-assets\/runtime\/install\.ps1/); - assert.match(publish, /test -s release-assets\/runtime\/ade-win32-x64\.exe/); - assert.match(publish, /test -s release-assets\/runtime\/ade-win32-x64\.native\.tar\.gz/); - assert.match(publish, /release-assets\/runtime\/install\.ps1[\s\S]*release-assets\/runtime\/ade-win32-x64\.exe[\s\S]*release-assets\/runtime\/ade-win32-x64\.native\.tar\.gz/); - assert.ok( - (publish.match(/\[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \] && \[ "\$WINDOWS_UPDATE_PROOF_APPROVED" = "1" \]/g) ?? []).length >= 3, - "copying, checksumming, and publishing standalone Windows assets must all require every gate", - ); + assert.doesNotMatch(publish, /runtime_assets\+=\(install\.ps1|release-assets\/runtime\/ade-win32-x64/); + assert.match(publish, /installers=\(release-assets\/win\/ADE-\*-win-x64\.exe\)/); + assert.match(publish, /test -s release-assets\/win\/install\.ps1/); + assert.match(publish, /test -s release-assets\/win\/ade-win32-x64\.exe/); + assert.match(publish, /test -s release-assets\/win\/ade-win32-x64\.native\.tar\.gz/); + assert.match(publish, /\(cd release-assets\/win && sha256sum -c SHA256SUMS\)/); + assert.match(publish, /release-assets\/win\/install\.ps1[\s\S]*release-assets\/win\/SHA256SUMS[\s\S]*release-assets\/win\/ade-\*/); + assert.match(publish, /\[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \] && \[ "\$WINDOWS_UPDATE_PROOF_APPROVED" = "1" \]/); assert.match(publish, /install\.ps1\|ade-win32-x64\.exe\|ade-win32-x64\.native\.tar\.gz/); assert.match(publish, /gh release delete-asset "\$TAG_NAME" "\$asset" --repo "\$GH_REPO" --yes/); assert.match(publish, /"\$BUILD_WINDOWS" != "1"[\s\S]*"\$PUBLISH_WINDOWS" != "1"[\s\S]*"\$WINDOWS_UPDATE_PROOF_APPROVED" != "1"/); @@ -329,8 +328,8 @@ test("Windows release assets are validated and published as one release set", () assert.match(publish, /BUILD_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED \}\}/); assert.match(publish, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); assert.match(publish, /if \[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \] && \[ "\$WINDOWS_UPDATE_PROOF_APPROVED" = "1" \]; then/); - assert.match(publish, /release-assets\/win\/\*\.exe/); - assert.match(publish, /release-assets\/win\/\*\.exe\.blockmap/); + assert.match(publish, /release-assets\/win\/ADE-\*-win-x64\.exe/); + assert.match(publish, /release-assets\/win\/ADE-\*-win-x64\.exe\.blockmap/); assert.match(publish, /release-assets\/win\/latest\.yml/); assert.match(publish, /--json isDraft/); assert.match(publish, /Refusing to overwrite published assets/); @@ -427,12 +426,12 @@ test("public Windows release promotes the approved immutable proof artifact", () assert.match(windowsRelease, /apps\/desktop\/release\/ade-\*/); assert.match(windowsRelease, /apps\/desktop\/release\/install\.ps1/); assert.match(windowsRelease, /apps\/desktop\/release\/SHA256SUMS/); - assert.match(windowsRelease, /inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); + assert.match(windowsRelease, /inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars\.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1'/); }); test("normal public workflow skips Windows promotion while its public gate is disabled", () => { const promotion = jobBlock(releaseWorkflow, "promote-approved-win-proof", "build-runtime-binaries"); - assert.match(promotion, /if: \$\{\{ inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \}\}/); + assert.match(promotion, /if: \$\{\{ inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars\.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' \}\}/); const publish = jobBlock(releaseWorkflow, "publish-release", null); assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'/); }); From e95578d105cbcfbfad8427e10f67458ab772a305 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 22:52:12 -0400 Subject: [PATCH 05/27] fix(windows): enforce exact release artifact identity Bind release tags to the approved target, replace draft assets with the exact validated inventory, and reject unauthorized runtime or checksum entries. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- .github/workflows/release-core.yml | 167 +++++++++++++++--- .../scripts/windows-proof-manifest.mjs | 52 ++++++ .../scripts/windows-proof-manifest.test.mjs | 47 ++++- .../scripts/windows-release-contract.test.mjs | 34 +++- 4 files changed, 268 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 374e894ff..2ff4c9b96 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -31,6 +31,26 @@ jobs: ref: ${{ inputs.target_ref }} fetch-depth: 0 + - name: Validate release tag and target binding + env: + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + if ! printf '%s' "$RELEASE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::Release tag must use the canonical v1.2.3 or v1.2.3-prerelease form." + exit 1 + fi + + target_sha="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then + git fetch --force --no-tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + tag_sha="$(git rev-list -n 1 "refs/tags/$RELEASE_TAG" | tr '[:upper:]' '[:lower:]')" + if [ "$tag_sha" != "$target_sha" ]; then + echo "::error::Release tag $RELEASE_TAG resolves to $tag_sha, not approved target $target_sha." + exit 1 + fi + fi + - name: Ensure release commit points to main run: | git fetch origin main:refs/remotes/origin/main @@ -376,15 +396,35 @@ jobs: $releaseDir = "apps/desktop/release" Copy-Item -LiteralPath "apps/ade-cli/scripts/install-runtime.sh" -Destination "$releaseDir/install.sh" Copy-Item -LiteralPath "apps/ade-cli/scripts/install-runtime.ps1" -Destination "$releaseDir/install.ps1" - Get-ChildItem -LiteralPath "apps/desktop/resources/runtime" -File -Filter "ade-*" | ForEach-Object { - Copy-Item -LiteralPath $_.FullName -Destination $releaseDir + $runtimeFiles = @( + "ade-darwin-arm64", + "ade-darwin-arm64.native.tar.gz", + "ade-darwin-x64", + "ade-darwin-x64.native.tar.gz", + "ade-linux-arm64", + "ade-linux-arm64.native.tar.gz", + "ade-linux-x64", + "ade-linux-x64.native.tar.gz", + "ade-win32-x64.exe", + "ade-win32-x64.native.tar.gz" + ) + $actualRuntimeFiles = @( + Get-ChildItem -LiteralPath "apps/desktop/resources/runtime" -File -Filter "ade-*" | + ForEach-Object Name | + Sort-Object + ) + $unexpectedRuntimeFiles = @($actualRuntimeFiles | Where-Object { $_ -notin $runtimeFiles }) + $missingRuntimeFiles = @($runtimeFiles | Where-Object { $_ -notin $actualRuntimeFiles }) + if ($unexpectedRuntimeFiles.Count -gt 0 -or $missingRuntimeFiles.Count -gt 0) { + throw "Runtime artifact inventory mismatch. Missing: $($missingRuntimeFiles -join ', '); unexpected: $($unexpectedRuntimeFiles -join ', ')." + } + foreach ($runtimeFile in $runtimeFiles) { + Copy-Item -LiteralPath "apps/desktop/resources/runtime/$runtimeFile" -Destination $releaseDir } - Get-Item -LiteralPath "$releaseDir/ade-win32-x64.exe" | Out-Null - Get-Item -LiteralPath "$releaseDir/ade-win32-x64.native.tar.gz" | Out-Null $checksumFiles = @( Get-Item -LiteralPath "$releaseDir/install.sh" Get-Item -LiteralPath "$releaseDir/install.ps1" - Get-ChildItem -LiteralPath $releaseDir -File -Filter "ade-*" + $runtimeFiles | ForEach-Object { Get-Item -LiteralPath "$releaseDir/$_" } ) | Sort-Object Name $checksumLines = $checksumFiles | ForEach-Object { $digest = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() @@ -426,9 +466,18 @@ jobs: with: name: ade-win-release-${{ inputs.release_tag }} path: | - apps/desktop/release/*.exe - apps/desktop/release/*.exe.blockmap - apps/desktop/release/ade-* + apps/desktop/release/ADE-*-win-x64.exe + apps/desktop/release/ADE-*-win-x64.exe.blockmap + apps/desktop/release/ade-darwin-arm64 + apps/desktop/release/ade-darwin-arm64.native.tar.gz + apps/desktop/release/ade-darwin-x64 + apps/desktop/release/ade-darwin-x64.native.tar.gz + apps/desktop/release/ade-linux-arm64 + apps/desktop/release/ade-linux-arm64.native.tar.gz + apps/desktop/release/ade-linux-x64 + apps/desktop/release/ade-linux-x64.native.tar.gz + apps/desktop/release/ade-win32-x64.exe + apps/desktop/release/ade-win32-x64.native.tar.gz apps/desktop/release/install.sh apps/desktop/release/install.ps1 apps/desktop/release/SHA256SUMS @@ -484,9 +533,18 @@ jobs: with: name: ade-win-release-${{ inputs.release_tag }} path: | - apps/desktop/release/*.exe - apps/desktop/release/*.exe.blockmap - apps/desktop/release/ade-* + apps/desktop/release/ADE-*-win-x64.exe + apps/desktop/release/ADE-*-win-x64.exe.blockmap + apps/desktop/release/ade-darwin-arm64 + apps/desktop/release/ade-darwin-arm64.native.tar.gz + apps/desktop/release/ade-darwin-x64 + apps/desktop/release/ade-darwin-x64.native.tar.gz + apps/desktop/release/ade-linux-arm64 + apps/desktop/release/ade-linux-arm64.native.tar.gz + apps/desktop/release/ade-linux-x64 + apps/desktop/release/ade-linux-x64.native.tar.gz + apps/desktop/release/ade-win32-x64.exe + apps/desktop/release/ade-win32-x64.native.tar.gz apps/desktop/release/install.sh apps/desktop/release/install.ps1 apps/desktop/release/SHA256SUMS @@ -808,6 +866,18 @@ jobs: run: | set -euo pipefail shopt -s nullglob + runtime_files=( + ade-darwin-arm64 + ade-darwin-arm64.native.tar.gz + ade-darwin-x64 + ade-darwin-x64.native.tar.gz + ade-linux-arm64 + ade-linux-arm64.native.tar.gz + ade-linux-x64 + ade-linux-x64.native.tar.gz + ade-win32-x64.exe + ade-win32-x64.native.tar.gz + ) installers=(release-assets/win/ADE-*-win-x64.exe) blockmaps=(release-assets/win/ADE-*-win-x64.exe.blockmap) if [ "${#installers[@]}" -ne 1 ] || [ "${#blockmaps[@]}" -ne 1 ]; then @@ -822,6 +892,26 @@ jobs: test -s release-assets/win/ade-win32-x64.exe test -s release-assets/win/ade-win32-x64.native.tar.gz test -s release-assets/win/SHA256SUMS + mapfile -t actual_runtime_files < <( + find release-assets/win -maxdepth 1 -type f -name 'ade-*' -printf '%f\n' | LC_ALL=C sort + ) + mapfile -t expected_runtime_files < <(printf '%s\n' "${runtime_files[@]}" | LC_ALL=C sort) + if ! diff -u <(printf '%s\n' "${expected_runtime_files[@]}") <(printf '%s\n' "${actual_runtime_files[@]}"); then + echo "::error::Windows proof contains an unauthorized or missing runtime artifact." + exit 1 + fi + if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' release-assets/win/SHA256SUMS; then + echo "::error::Windows proof checksum manifest has an invalid entry." + exit 1 + fi + mapfile -t actual_checksum_files < <(awk '{ print $2 }' release-assets/win/SHA256SUMS | LC_ALL=C sort) + mapfile -t expected_checksum_files < <( + printf '%s\n' install.sh install.ps1 "${runtime_files[@]}" | LC_ALL=C sort + ) + if ! diff -u <(printf '%s\n' "${expected_checksum_files[@]}") <(printf '%s\n' "${actual_checksum_files[@]}"); then + echo "::error::Windows proof checksum manifest does not name the exact authorized runtime set." + exit 1 + fi (cd release-assets/win && sha256sum -c SHA256SUMS) - name: Create or update draft GitHub release env: @@ -833,6 +923,7 @@ jobs: PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} run: | + set -euo pipefail shopt -s nullglob # The per-arch macOS zips + latest-mac.yml are what electron-updater # consumes; DMGs are the human downloads. Mac blockmaps stay omitted. @@ -854,16 +945,25 @@ jobs: # This repository variable stays disabled until the signed installer # passes the clean-host release checks. if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then - files=( - "${base_files[@]}" + windows_files=( release-assets/win/ADE-*-win-x64.exe release-assets/win/ADE-*-win-x64.exe.blockmap release-assets/win/latest.yml release-assets/win/install.sh release-assets/win/install.ps1 release-assets/win/SHA256SUMS - release-assets/win/ade-* + release-assets/win/ade-darwin-arm64 + release-assets/win/ade-darwin-arm64.native.tar.gz + release-assets/win/ade-darwin-x64 + release-assets/win/ade-darwin-x64.native.tar.gz + release-assets/win/ade-linux-arm64 + release-assets/win/ade-linux-arm64.native.tar.gz + release-assets/win/ade-linux-x64 + release-assets/win/ade-linux-x64.native.tar.gz + release-assets/win/ade-win32-x64.exe + release-assets/win/ade-win32-x64.native.tar.gz ) + files=("${base_files[@]}" "${windows_files[@]}") fi if [ "${#files[@]}" -eq 0 ]; then @@ -877,18 +977,18 @@ jobs: echo "::error::Release $TAG_NAME is already public. Refusing to overwrite published assets." exit 1 fi - if [ "$BUILD_WINDOWS" != "1" ] || [ "$PUBLISH_WINDOWS" != "1" ] || [ "$WINDOWS_UPDATE_PROOF_APPROVED" != "1" ]; then - mapfile -t existing_assets < <( - gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' - ) - for asset in "${existing_assets[@]}"; do - case "$asset" in - install.ps1|ade-win32-x64.exe|ade-win32-x64.native.tar.gz|ADE*-win-x64.exe|ADE*-win-x64.exe.blockmap|latest.yml) - gh release delete-asset "$TAG_NAME" "$asset" --repo "$GH_REPO" --yes - ;; - esac - done + approved_target="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + release_tag_target="$(gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq '.sha' | tr '[:upper:]' '[:lower:]')" + if [ "$release_tag_target" != "$approved_target" ]; then + echo "::error::Existing draft tag $TAG_NAME resolves to $release_tag_target, not approved target $approved_target." + exit 1 fi + mapfile -t existing_assets < <( + gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' + ) + for asset in "${existing_assets[@]}"; do + gh release delete-asset "$TAG_NAME" "$asset" --repo "$GH_REPO" --yes + done gh release upload "$TAG_NAME" "${files[@]}" --repo "$GH_REPO" --clobber else gh release create "$TAG_NAME" "${files[@]}" \ @@ -898,3 +998,20 @@ jobs: --generate-notes \ --target "$TARGET_REF" fi + + approved_target="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + final_tag_target="$(gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq '.sha' | tr '[:upper:]' '[:lower:]')" + if [ "$final_tag_target" != "$approved_target" ]; then + echo "::error::Draft release tag $TAG_NAME resolves to $final_tag_target, not approved target $approved_target." + exit 1 + fi + mapfile -t expected_assets < <( + for file in "${files[@]}"; do basename "$file"; done | LC_ALL=C sort + ) + mapfile -t actual_assets < <( + gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' | LC_ALL=C sort + ) + if ! diff -u <(printf '%s\n' "${expected_assets[@]}") <(printf '%s\n' "${actual_assets[@]}"); then + echo "::error::Draft release asset inventory differs from the exact validated set." + exit 1 + fi diff --git a/apps/desktop/scripts/windows-proof-manifest.mjs b/apps/desktop/scripts/windows-proof-manifest.mjs index b3c0ec6e0..06fe75ab2 100644 --- a/apps/desktop/scripts/windows-proof-manifest.mjs +++ b/apps/desktop/scripts/windows-proof-manifest.mjs @@ -22,6 +22,23 @@ const SAFE_HOST_ALIAS_PATTERN = /^win(?:10|11)-[a-z0-9][a-z0-9-]*$/; const MAX_EVIDENCE_BYTES = 20 * 1024 * 1024; const GUI_EVIDENCE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]); const STRUCTURED_EVIDENCE_EXTENSIONS = new Set([".csv", ".json", ".jsonl", ".log", ".txt"]); +const AUTHORIZED_RUNTIME_FILES = [ + "ade-darwin-arm64", + "ade-darwin-arm64.native.tar.gz", + "ade-darwin-x64", + "ade-darwin-x64.native.tar.gz", + "ade-linux-arm64", + "ade-linux-arm64.native.tar.gz", + "ade-linux-x64", + "ade-linux-x64.native.tar.gz", + "ade-win32-x64.exe", + "ade-win32-x64.native.tar.gz", +]; +const AUTHORIZED_RUNTIME_CHECKSUM_FILES = [ + "install.sh", + "install.ps1", + ...AUTHORIZED_RUNTIME_FILES, +]; const REDACTED_VALUE_PATTERNS = [ { pattern: /\bBearer\s+[A-Za-z0-9._~+\/-]+=*/i, label: "bearer credential" }, { pattern: /\b(?:gh[pousr]_|github_pat_|sk-|xox[baprs]-)[A-Za-z0-9_-]{8,}/i, label: "token-shaped value" }, @@ -378,6 +395,25 @@ function validateRuntimeChecksums(errors, artifactRoot, artifacts) { listed.set(file, digest); } + const authorizedChecksums = new Set(AUTHORIZED_RUNTIME_CHECKSUM_FILES); + for (const file of listed.keys()) { + if (!authorizedChecksums.has(file)) { + addError(errors, "artifacts.runtime-checksums", `contains unauthorized entry ${file}`); + } + } + for (const file of AUTHORIZED_RUNTIME_CHECKSUM_FILES) { + if (!listed.has(file)) { + addError(errors, "artifacts.runtime-checksums", `is missing authorized runtime file ${file}`); + continue; + } + const runtimePath = path.join(path.resolve(artifactRoot), file); + if (!fs.existsSync(runtimePath) || !fs.statSync(runtimePath).isFile()) { + addError(errors, "artifactRoot", `is missing checksummed runtime file ${file}`); + } else if (sha256File(runtimePath) !== listed.get(file)) { + addError(errors, "artifacts.runtime-checksums", `does not match runtime file ${file}`); + } + } + for (const role of ["standalone-runtime", "standalone-native-archive", "standalone-installer"]) { const artifact = byRole.get(role); if (!artifact || typeof artifact.file !== "string" || typeof artifact.sha256 !== "string") continue; @@ -387,6 +423,22 @@ function validateRuntimeChecksums(errors, artifactRoot, artifacts) { addError(errors, "artifacts.runtime-checksums", `does not bind the declared SHA-256 for ${artifact.file}`); } } + + const runtimeFiles = fs.readdirSync(path.resolve(artifactRoot), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.startsWith("ade-")) + .map((entry) => entry.name) + .sort(); + const expectedRuntimeFiles = [...AUTHORIZED_RUNTIME_FILES].sort(); + for (const file of runtimeFiles) { + if (!AUTHORIZED_RUNTIME_FILES.includes(file)) { + addError(errors, "artifactRoot", `contains unauthorized runtime file ${file}`); + } + } + for (const file of expectedRuntimeFiles) { + if (!runtimeFiles.includes(file)) { + addError(errors, "artifactRoot", `is missing authorized runtime file ${file}`); + } + } } function verifyIndexedFile(errors, root, entry, field, { scanText = false } = {}) { diff --git a/apps/desktop/scripts/windows-proof-manifest.test.mjs b/apps/desktop/scripts/windows-proof-manifest.test.mjs index 339e34b41..96f470708 100644 --- a/apps/desktop/scripts/windows-proof-manifest.test.mjs +++ b/apps/desktop/scripts/windows-proof-manifest.test.mjs @@ -20,6 +20,18 @@ const provenancePath = path.join(repoRoot, "docs", "development", "windows-sourc const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8")); const provenance = JSON.parse(fs.readFileSync(provenancePath, "utf8")); const targetSha = "0123456789abcdef0123456789abcdef01234567"; +const runtimeFiles = [ + "ade-darwin-arm64", + "ade-darwin-arm64.native.tar.gz", + "ade-darwin-x64", + "ade-darwin-x64.native.tar.gz", + "ade-linux-arm64", + "ade-linux-arm64.native.tar.gz", + "ade-linux-x64", + "ade-linux-x64.native.tar.gz", + "ade-win32-x64.exe", + "ade-win32-x64.native.tar.gz", +]; function clone(value) { return JSON.parse(JSON.stringify(value)); @@ -34,10 +46,12 @@ function createFixture() { fs.writeFileSync(path.join(releaseDir, "ADE-1.2.3-win-x64.exe"), "signed installer fixture"); fs.writeFileSync(path.join(releaseDir, "ADE-1.2.3-win-x64.exe.blockmap"), "blockmap fixture"); fs.writeFileSync(path.join(releaseDir, "latest.yml"), "version: 1.2.3\n"); - fs.writeFileSync(path.join(releaseDir, "ade-win32-x64.exe"), "standalone runtime fixture"); - fs.writeFileSync(path.join(releaseDir, "ade-win32-x64.native.tar.gz"), "standalone native archive fixture"); + for (const file of runtimeFiles) { + fs.writeFileSync(path.join(releaseDir, file), `${file} fixture`); + } + fs.writeFileSync(path.join(releaseDir, "install.sh"), "#!/bin/sh\n"); fs.writeFileSync(path.join(releaseDir, "install.ps1"), "Write-Output 'install fixture'\n"); - const checksummedFiles = ["ade-win32-x64.exe", "ade-win32-x64.native.tar.gz", "install.ps1"]; + const checksummedFiles = ["install.sh", "install.ps1", ...runtimeFiles].sort(); fs.writeFileSync(path.join(releaseDir, "SHA256SUMS"), checksummedFiles .map((file) => `${sha256File(path.join(releaseDir, file))} ${file}`) .join("\n") + "\n"); @@ -235,6 +249,33 @@ test("build validation rejects a checksum manifest that does not bind standalone assert.ok(errors.some((error) => error.includes("does not bind the declared SHA-256 for ade-win32-x64.exe"))); }); +test("build validation rejects unauthorized runtime files and checksum entries", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + const unexpectedFile = "ade-win32-arm64.exe"; + fs.writeFileSync(path.join(fixture.releaseDir, unexpectedFile), "unexpected runtime fixture"); + const checksumPath = path.join(fixture.releaseDir, "SHA256SUMS"); + fs.appendFileSync( + checksumPath, + `${sha256File(path.join(fixture.releaseDir, unexpectedFile))} ${unexpectedFile}\n`, + ); + const checksumArtifact = fixture.manifest.artifacts.find(({ role }) => role === "runtime-checksums"); + checksumArtifact.sha256 = sha256File(checksumPath); + checksumArtifact.sizeBytes = fs.statSync(checksumPath).size; + + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes(`contains unauthorized entry ${unexpectedFile}`))); + assert.ok(errors.some((error) => error.includes(`contains unauthorized runtime file ${unexpectedFile}`))); +}); + test("complete validation re-hashes indexed evidence and enforces independent signals", (t) => { const fixture = createFixture(); t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index c90ad7101..cc4789b2f 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -283,11 +283,12 @@ test("standalone Windows release assets remain behind every publication and proo assert.match(publish, /test -s release-assets\/win\/ade-win32-x64\.exe/); assert.match(publish, /test -s release-assets\/win\/ade-win32-x64\.native\.tar\.gz/); assert.match(publish, /\(cd release-assets\/win && sha256sum -c SHA256SUMS\)/); - assert.match(publish, /release-assets\/win\/install\.ps1[\s\S]*release-assets\/win\/SHA256SUMS[\s\S]*release-assets\/win\/ade-\*/); + assert.match(publish, /release-assets\/win\/install\.ps1[\s\S]*release-assets\/win\/SHA256SUMS[\s\S]*release-assets\/win\/ade-darwin-arm64[\s\S]*release-assets\/win\/ade-win32-x64\.exe/); + assert.doesNotMatch(publish, /release-assets\/win\/ade-\*/); assert.match(publish, /\[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \] && \[ "\$WINDOWS_UPDATE_PROOF_APPROVED" = "1" \]/); - assert.match(publish, /install\.ps1\|ade-win32-x64\.exe\|ade-win32-x64\.native\.tar\.gz/); + assert.match(publish, /for asset in "\$\{existing_assets\[@\]\}"; do[\s\S]*gh release delete-asset/); assert.match(publish, /gh release delete-asset "\$TAG_NAME" "\$asset" --repo "\$GH_REPO" --yes/); - assert.match(publish, /"\$BUILD_WINDOWS" != "1"[\s\S]*"\$PUBLISH_WINDOWS" != "1"[\s\S]*"\$WINDOWS_UPDATE_PROOF_APPROVED" != "1"/); + assert.match(publish, /Draft release asset inventory differs from the exact validated set/); assert.match(runtimeBuild, /name: Assemble signed Windows standalone proof bundle/); assert.match(runtimeBuild, /matrix\.target == 'win32-x64' && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1'/); assert.match(runtimeBuild, /sha256sum install\.ps1 ade-win32-x64\.exe ade-win32-x64\.native\.tar\.gz/); @@ -386,11 +387,17 @@ test("Windows NSIS install and uninstall own only their per-user channel integra }); test("release preflight validates the exact approved commit", () => { + const verify = jobBlock(releaseWorkflow, "verify", "build-mac-release"); assert.match(prepareWorkflow, /target_sha:\s*\n\s+description: Exact 40-character commit SHA/); assert.match(prepareWorkflow, /ref: \$\{\{ inputs\.target_sha \}\}/); assert.match(prepareWorkflow, /target_sha must be the exact 40-character commit SHA approved for release/); assert.match(prepareWorkflow, /target_ref: \$\{\{ needs\.resolve\.outputs\.target_sha \}\}/); assert.doesNotMatch(prepareWorkflow, /ref: main/); + assert.match(verify, /name: Validate release tag and target binding/); + assert.match(verify, /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+/); + assert.match(verify, /git ls-remote --exit-code --tags origin "refs\/tags\/\$RELEASE_TAG"/); + assert.match(verify, /git rev-list -n 1 "refs\/tags\/\$RELEASE_TAG"/); + assert.match(verify, /Release tag \$RELEASE_TAG resolves to \$tag_sha, not approved target \$target_sha/); }); test("signed Windows proof workflow is non-publishing and emits an exact-SHA manifest", () => { @@ -423,12 +430,31 @@ test("public Windows release promotes the approved immutable proof artifact", () assert.match(windowsRelease, /Get-FileHash -LiteralPath \$manifestPath -Algorithm SHA256/); assert.match(windowsRelease, /APPROVED_BUILD_MANIFEST_SHA256/); assert.match(windowsRelease, /--expected-run-id "\$\{\{ vars\.ADE_WINDOWS_APPROVED_PROOF_RUN_ID \}\}"/); - assert.match(windowsRelease, /apps\/desktop\/release\/ade-\*/); + assert.match(windowsRelease, /apps\/desktop\/release\/ade-darwin-arm64/); + assert.match(windowsRelease, /apps\/desktop\/release\/ade-win32-x64\.exe/); + assert.doesNotMatch(windowsRelease, /apps\/desktop\/release\/ade-\*/); assert.match(windowsRelease, /apps\/desktop\/release\/install\.ps1/); assert.match(windowsRelease, /apps\/desktop\/release\/SHA256SUMS/); assert.match(windowsRelease, /inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars\.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1'/); }); +test("Windows proof and draft assembly enforce exact runtime and remote asset inventories", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-proof", "promote-approved-win-proof"); + const publish = jobBlock(releaseWorkflow, "publish-release", null); + assert.match(windowsRelease, /\$unexpectedRuntimeFiles = @\(\$actualRuntimeFiles \| Where-Object \{ \$_ -notin \$runtimeFiles \}\)/); + assert.match(windowsRelease, /Runtime artifact inventory mismatch/); + assert.doesNotMatch(windowsRelease, /Get-ChildItem[^\n]+-Filter "ade-\*"[^\n]+ForEach-Object \{/); + assert.match(publish, /Windows proof contains an unauthorized or missing runtime artifact/); + assert.match(publish, /Windows proof checksum manifest does not name the exact authorized runtime set/); + assert.match(publish, /gh api "repos\/\$GH_REPO\/commits\/\$TAG_NAME" --jq '\.sha'/); + assert.match(publish, /Existing draft tag \$TAG_NAME resolves to \$release_tag_target, not approved target \$approved_target/); + assert.match(publish, /Draft release tag \$TAG_NAME resolves to \$final_tag_target, not approved target \$approved_target/); + assert.match(publish, /for asset in "\$\{existing_assets\[@\]\}"; do[\s\S]*gh release delete-asset/); + assert.doesNotMatch(publish, /case "\$asset" in/); + assert.match(publish, /Draft release asset inventory differs from the exact validated set/); + assert.match(publish, /gh release view "\$TAG_NAME" --repo "\$GH_REPO" --json assets --jq '\.assets\[\]\.name'/); +}); + test("normal public workflow skips Windows promotion while its public gate is disabled", () => { const promotion = jobBlock(releaseWorkflow, "promote-approved-win-proof", "build-runtime-binaries"); assert.match(promotion, /if: \$\{\{ inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars\.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' \}\}/); From 0a157821fb482a65cab66b6a3ee66c2b33bae84a Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:21:48 -0400 Subject: [PATCH 06/27] fix(release): restore platform-neutral prepare-release dry run PR5 renamed prepare-release.yml to "Prepare signed Windows proof" and made its resolve job hard-fail unless ADE_WINDOWS_SIGNED_BUILD_ENABLED=1 and ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != 1. That destroyed the pre-existing platform-neutral non-publishing validation run for macOS and the standalone runtime: in today's flag state, with no Windows variables set, the workflow cannot be dispatched at all. It was also circular, because enabling public Windows releases made the proof workflow unrunnable until the same gate was turned back off. Restore the workflow as the platform-neutral dry run it has always been and move the Windows proof-mode preconditions behind an explicit windows_proof dispatch input, so both uses coexist. Proof mode still refuses to run while publication is enabled, which keeps the documented "collect proof with the public gate off" rule, but that requirement no longer applies to ordinary validation runs. Based-on: nsxdavid/ADE#999 (cherry picked from commit d7040859f5b27fb84388efc9c0082e03fd91b9f3) --- .github/workflows/prepare-release.yml | 43 ++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 857d7bd4e..76e7b8784 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,4 +1,9 @@ -name: Prepare signed Windows proof +name: Prepare release + +# Platform-neutral, non-publishing validation run. It never creates or updates a +# GitHub Release. Set windows_proof when the run is being used to collect the +# signed Windows exact-SHA proof; the Windows-specific preconditions are then +# asserted before anything is built. on: workflow_dispatch: @@ -11,6 +16,15 @@ on: description: Exact 40-character commit SHA on main to validate. required: true type: string + windows_proof: + description: >- + Collect the signed Windows exact-SHA proof. Requires + ADE_WINDOWS_SIGNED_BUILD_ENABLED=1 and + ADE_WINDOWS_PUBLIC_RELEASE_ENABLED other than 1. Leave false for the + platform-neutral macOS and standalone runtime dry run. + required: false + default: false + type: boolean permissions: actions: read @@ -24,16 +38,12 @@ jobs: tag_name: ${{ steps.resolve.outputs.tag_name }} target_sha: ${{ steps.resolve.outputs.target_sha }} steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.target_sha }} - fetch-depth: 0 - - - name: Resolve version and target commit - id: resolve + # Opt-in mode. Without it this workflow stays the platform-neutral dry run + # that macOS and standalone runtime releases have always used, so it stays + # runnable no matter how the Windows repository variables are set. + - name: Prepare signed Windows proof mode + if: ${{ inputs.windows_proof }} env: - INPUT_VERSION: ${{ inputs.version }} - INPUT_TARGET_SHA: ${{ inputs.target_sha }} BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} run: | @@ -48,6 +58,19 @@ jobs: exit 1 fi + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_sha }} + fetch-depth: 0 + + - name: Resolve version and target commit + id: resolve + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_TARGET_SHA: ${{ inputs.target_sha }} + run: | + set -euo pipefail + version="$(printf '%s' "$INPUT_VERSION" | tr -d '[:space:]')" if [ -z "$version" ]; then echo "::error::Version input cannot be empty." From 24b333469cbabb9748e3ad1831bccccba081567c Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:22:05 -0400 Subject: [PATCH 07/27] fix(release): pin and check approved Windows proof artifact retention promote-approved-win-proof downloads ade-win-release- from an arbitrarily old run named by ADE_WINDOWS_APPROVED_PROOF_RUN_ID, but the proof upload set no retention-days. Once the approved run's artifacts aged out, or once a lowered repository or organization default shortened the window, a tagged release failed with an opaque download error. Pin retention-days: 90 on the proof upload and on the promotion re-upload, and check that the named artifact still exists and has not expired before downloading it. The check distinguishes missing, expired and unreadable and names the recovery path in each case: re-run the proof for the approved commit and rebind ADE_WINDOWS_APPROVED_PROOF_RUN_ID and ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256. Based-on: nsxdavid/ADE#999 (cherry picked from commit a4308f34b57c25a52aac4bc0ed507b3a96560d7a) --- .github/workflows/release-core.yml | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 2ff4c9b96..87364a582 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -465,6 +465,12 @@ jobs: uses: actions/upload-artifact@v4 with: name: ade-win-release-${{ inputs.release_tag }} + # A public release promotes these exact bytes from this run, so the + # artifact has to outlive approval. Pinned instead of inherited: a + # lowered repository or organization default would silently shorten + # the window in which the approved proof can still be released. + # Keep this in sync with docs/playbooks/windows-signed-release.md. + retention-days: 90 path: | apps/desktop/release/ADE-*-win-x64.exe apps/desktop/release/ADE-*-win-x64.exe.blockmap @@ -502,6 +508,51 @@ jobs: with: node-version: 22 + - name: Confirm approved Windows proof artifact is still retained + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + APPROVED_PROOF_RUN_ID: ${{ vars.ADE_WINDOWS_APPROVED_PROOF_RUN_ID }} + ARTIFACT_NAME: ade-win-release-${{ inputs.release_tag }} + run: | + set -uo pipefail + + if ! status="$( + gh api "repos/$GH_REPO/actions/runs/$APPROVED_PROOF_RUN_ID/artifacts" \ + --method GET \ + -H "Accept: application/vnd.github+json" \ + -f per_page=100 \ + --jq ' + [.artifacts[] | select(.name == env.ARTIFACT_NAME)] as $named + | if ($named | length) == 0 then "missing" + elif ([$named[] | select(.expired == false)] | length) == 0 then "expired" + else "available" end + ' + )"; then + status="unreadable" + fi + + recovery="Re-run prepare-release.yml with windows_proof=true for the approved commit while ADE_WINDOWS_PUBLIC_RELEASE_ENABLED is not 1, re-approve the proof, then update ADE_WINDOWS_APPROVED_PROOF_RUN_ID and ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256." + + case "$status" in + available) + echo "Approved Windows proof artifact $ARTIFACT_NAME from run $APPROVED_PROOF_RUN_ID is still retained." + ;; + expired) + echo "::error::Approved Windows proof artifact $ARTIFACT_NAME from run $APPROVED_PROOF_RUN_ID has passed its 90-day retention window and can no longer be downloaded. $recovery" + exit 1 + ;; + missing) + echo "::error::Approved Windows proof run $APPROVED_PROOF_RUN_ID has no artifact named $ARTIFACT_NAME. ADE_WINDOWS_APPROVED_PROOF_RUN_ID may point at the wrong run, or the release tag may differ from the proven one. $recovery" + exit 1 + ;; + *) + echo "::error::Could not read artifacts for approved Windows proof run $APPROVED_PROOF_RUN_ID. Confirm ADE_WINDOWS_APPROVED_PROOF_RUN_ID names a run in $GH_REPO that has not been deleted. $recovery" + exit 1 + ;; + esac + - name: Download approved immutable Windows proof artifact uses: actions/download-artifact@v4 with: @@ -532,6 +583,10 @@ jobs: uses: actions/upload-artifact@v4 with: name: ade-win-release-${{ inputs.release_tag }} + # Re-published copy of the approved proof, consumed by publish-release + # in this same run. Pinned so a shortened default cannot expire it + # between promotion and draft assembly. + retention-days: 90 path: | apps/desktop/release/ADE-*-win-x64.exe apps/desktop/release/ADE-*-win-x64.exe.blockmap From cd21e6261d520c5783ddee942553fb3b415ccefc Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:24:04 -0400 Subject: [PATCH 08/27] fix(release): publish runtime assets from the run that built them When the Windows gate was on, publish-release rebuilt the upload list as base_files + windows_files, which dropped release-assets/runtime entirely. windows_files supplied install.sh, SHA256SUMS and all ten ade-darwin-*, ade-linux-* and ade-win32-x64 runtime binaries from release-assets/win, that is from the earlier approved proof run, while the macOS DMG and zip came from the current run. Darwin runtime binaries are notarized per run, so the published standalone ade-darwin-* were not byte-identical to the ones inside the DMG shipped beside them, and the published SHA256SUMS described the proof run's bytes rather than the uploaded ones. The job also generated and validated release-assets/runtime/SHA256SUMS and then never uploaded it. Publish the cross-platform standalone set from the current run and take only the genuinely Windows-specific files from the approved proof: the installer, its blockmap, latest.yml, install.ps1, and the signed ade-win32-x64 executable and native archive. Generate one SHA256SUMS over that exact merged set, and verify it names precisely the published standalone assets. The published asset names are unchanged in every flag state, so install-runtime.sh and install-runtime.ps1 keep resolving the same entries. The proof bundle's own inventory and checksum allowlist are untouched and still validate the full ten-file cross-platform set plus install.sh, install.ps1 and its SHA256SUMS; those superseded members are now asserted present as proof-internal files rather than published. Based-on: nsxdavid/ADE#999 (cherry picked from commit a3b0205afd74bfd00c8dc410f2d7789f74de790c) --- .github/workflows/release-core.yml | 87 +++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 87364a582..727498b20 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -849,22 +849,10 @@ jobs: cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh chmod 755 release-assets/runtime/install.sh - - name: Generate standalone runtime checksums - run: | - set -euo pipefail - runtime_assets=( - install.sh - ade-darwin-arm64 - ade-darwin-arm64.native.tar.gz - ade-darwin-x64 - ade-darwin-x64.native.tar.gz - ade-linux-arm64 - ade-linux-arm64.native.tar.gz - ade-linux-x64 - ade-linux-x64.native.tar.gz - ) - (cd release-assets/runtime && sha256sum "${runtime_assets[@]}" | LC_ALL=C sort -k2 > SHA256SUMS) - + # The published SHA256SUMS is generated later, over the exact merged set of + # standalone assets this release uploads. Hashing release-assets/runtime on + # its own would describe a set that is never published once the Windows + # gate contributes the signed win32 standalone files. - name: Validate publish asset manifest run: | set -euo pipefail @@ -901,8 +889,6 @@ jobs: echo "::error::Standalone runtime installer is not executable." exit 1 fi - require_file 'release-assets/runtime/SHA256SUMS' 'standalone runtime checksum manifest' - (cd release-assets/runtime && sha256sum -c SHA256SUMS) for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do binary="release-assets/runtime/ade-$target" @@ -989,23 +975,27 @@ jobs: release-assets/mac/*.zip release-assets/mac/latest-mac.yml ) + # Cross-platform standalone assets always come from this run. The + # darwin runtime binaries are notarized per run, so only this run's + # copies are byte-identical to the ones inside the DMGs published + # beside them. runtime_files=( release-assets/runtime/install.sh - release-assets/runtime/SHA256SUMS release-assets/runtime/ade-darwin-* release-assets/runtime/ade-linux-* ) files=("${base_files[@]}" "${runtime_files[@]}") + checksum_files=("${runtime_files[@]}") # This repository variable stays disabled until the signed installer # passes the clean-host release checks. if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then - windows_files=( - release-assets/win/ADE-*-win-x64.exe - release-assets/win/ADE-*-win-x64.exe.blockmap - release-assets/win/latest.yml + # The approved proof bundle also carries the cross-platform runtime + # set and its own checksum manifest. Those bytes belong to the older + # proof run and are superseded by the current run's notarized + # copies, so they stay proof-internal and are never published. + proof_internal_files=( release-assets/win/install.sh - release-assets/win/install.ps1 release-assets/win/SHA256SUMS release-assets/win/ade-darwin-arm64 release-assets/win/ade-darwin-arm64.native.tar.gz @@ -1015,11 +1005,58 @@ jobs: release-assets/win/ade-linux-arm64.native.tar.gz release-assets/win/ade-linux-x64 release-assets/win/ade-linux-x64.native.tar.gz + ) + for proof_internal_file in "${proof_internal_files[@]}"; do + if [ ! -s "$proof_internal_file" ]; then + echo "::error::Approved Windows proof is missing $proof_internal_file." + exit 1 + fi + done + # Only the genuinely Windows-specific members of the approved proof + # are published, so no asset name is served by two different runs. + windows_files=( + release-assets/win/ADE-*-win-x64.exe + release-assets/win/ADE-*-win-x64.exe.blockmap + release-assets/win/latest.yml + release-assets/win/install.ps1 release-assets/win/ade-win32-x64.exe release-assets/win/ade-win32-x64.native.tar.gz ) - files=("${base_files[@]}" "${windows_files[@]}") + files=("${files[@]}" "${windows_files[@]}") + checksum_files+=( + release-assets/win/install.ps1 + release-assets/win/ade-win32-x64.exe + release-assets/win/ade-win32-x64.native.tar.gz + ) + fi + + # One checksum manifest over exactly the standalone assets uploaded + # below, regenerated because the published set merges this run's + # notarized cross-platform binaries with the approved proof run's + # signed Windows binaries. install-runtime.sh and install-runtime.ps1 + # resolve their own platform's entries from this file. + mkdir -p release-assets/publish + published_checksums=release-assets/publish/SHA256SUMS + for checksum_file in "${checksum_files[@]}"; do + printf '%s %s\n' \ + "$(sha256sum "$checksum_file" | cut -d ' ' -f 1)" \ + "$(basename "$checksum_file")" + done | LC_ALL=C sort -k2 > "$published_checksums" + if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' "$published_checksums"; then + echo "::error::Published checksum manifest has an invalid entry." + exit 1 + fi + mapfile -t expected_published_checksums < <( + for checksum_file in "${checksum_files[@]}"; do basename "$checksum_file"; done | LC_ALL=C sort + ) + mapfile -t actual_published_checksums < <(awk '{ print $2 }' "$published_checksums") + if ! diff -u \ + <(printf '%s\n' "${expected_published_checksums[@]}") \ + <(printf '%s\n' "${actual_published_checksums[@]}"); then + echo "::error::Published checksum manifest does not name the exact published standalone asset set." + exit 1 fi + files+=("$published_checksums") if [ "${#files[@]}" -eq 0 ]; then echo "::error::No release artifacts were found after validation." From 8c41ebfd72dcc5d259879971c806327aa9d46e6d Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:24:05 -0400 Subject: [PATCH 09/27] docs(release): document every required release gate and setting ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED is required at six sites in release-core.yml but appeared nowhere in the Windows release playbook. A maintainer following that playbook exactly would set the signed-build flag, the three APPROVED_* bindings and the public flag, tag a release, and have the run die in verify with "requires approved two-version installed-update proof." Document it in "Enable Windows releases" and in the pre-tag checklist: what it attests, what must be true before setting it, and where the supporting evidence lives. Add a "Required GitHub Actions settings" reference covering every repository variable and secret release-core.yml actually reads, audited by grep against the workflow, including the pre-existing macOS signing and notarization secrets the playbook had never named. Also document the signed Windows proof mode input, the 90-day approved-proof artifact retention window and its recovery path, and the deliberate two-run provenance of the published asset set. Based-on: nsxdavid/ADE#999 (cherry picked from commit a56345e0733dcebdccba42dff536af65fb0853f0) --- docs/ARCHITECTURE.md | 4 +- docs/playbooks/windows-signed-release.md | 143 ++++++++++++++++++++--- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a250040ea..737d1f7ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1515,7 +1515,9 @@ Windows: - Electron-builder generates `resources/app-update.yml` from the GitHub publish configuration. The source default remains the upstream `arul28/ADE`; CI sets `ADE_RELEASE_REPOSITORY=${{ github.repository }}` so fork package validation proves the installed updater authority matches the repository that built it without changing upstream release behavior. - `release-core.yml` runs the signed Windows job only when repository variable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`. If enabled, missing signing credentials or pinned publisher identity fail both the desktop and standalone-runtime signing paths and block publication; if disabled, the skipped Windows job does not block the macOS release. The installer, updater metadata, `install.ps1`, signed `ade-win32-x64.exe`, and its native archive are added to the draft only when both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`. Keep both publication gates off until clean-host install checks and the mandatory two-unpublished-version N-to-N+1 signed update proof pass; that proof includes timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. - `release-core.yml` keeps Windows proof building and publication promotion as separate jobs. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, the non-publishing proof job requires signing credentials and a pinned publisher identity for the desktop and standalone runtime. A normal publishing workflow skips Windows cleanly while `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is off. Once the public and installed-update-proof gates are on, promotion retrieves the immutable approved proof-run artifact instead of signing or rebuilding a second set. If signed builds are disabled, the skipped Windows jobs do not block the macOS release. -- The non-publishing `prepare-release.yml` path requires signed Windows builds while public publication is off. It emits `windows-proof-manifest.json` for the exact 40-character source SHA and hashes the installer, blockmap, updater manifest, standalone executable, native archive, `install.ps1`, `SHA256SUMS`, full-system scenario inventory, and #999 provenance index. It verifies that the checksum manifest binds the standalone files. Public Windows publication additionally requires the protected exact SHA, proof run id, original build-manifest digest, and installed-update approval. Promotion validates those values plus the release tag and file hashes before draft assembly; the website download flag remains a separate disabled-by-default gate. See `docs/development/windows-release-proof.md`. +- `prepare-release.yml` is the platform-neutral non-publishing dry run and stays invokable in every Windows flag state. Its `windows_proof` dispatch input opts into signed Windows proof mode, which additionally requires `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` with `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` other than `1` so proof is always collected while publication is disabled. Proof mode emits `windows-proof-manifest.json` for the exact 40-character source SHA and hashes the installer, blockmap, updater manifest, standalone executable, native archive, `install.ps1`, `SHA256SUMS`, full-system scenario inventory, and #999 provenance index. It verifies that the checksum manifest binds the standalone files. Public Windows publication additionally requires the protected exact SHA, proof run id, original build-manifest digest, and installed-update approval. Promotion validates those values plus the release tag and file hashes before draft assembly; the website download flag remains a separate disabled-by-default gate. See `docs/development/windows-release-proof.md`. +- The approved proof artifact is uploaded with an explicit 90-day `retention-days`, and promotion checks that it is still retained before downloading it so an aged-out proof fails with a named recovery path instead of a raw download error. +- Draft assembly never mixes runs for one asset name. The published cross-platform standalone set (`install.sh`, `ade-darwin-*`, `ade-linux-*`) always comes from the current run, because darwin runtime binaries are notarized per run and must stay byte-identical to the copies inside the DMGs published beside them. Only the genuinely Windows-specific members of the approved proof are published: the installer, its blockmap, `latest.yml`, `install.ps1`, and the signed `ade-win32-x64` executable and native archive. The published `SHA256SUMS` is regenerated over that merged set, so the digests the runtime installers verify always describe the bytes actually uploaded; the proof bundle's own `SHA256SUMS` stays proof-internal. - Ongoing Windows integration lane (rebase with `main`, smoke tests, backlog): `docs/development/windows-port-lane.md`. Post-packaging hardening (`apps/desktop/scripts/`): diff --git a/docs/playbooks/windows-signed-release.md b/docs/playbooks/windows-signed-release.md index 5a6c5397f..f9fa292ec 100644 --- a/docs/playbooks/windows-signed-release.md +++ b/docs/playbooks/windows-signed-release.md @@ -25,7 +25,9 @@ Complete these actions in order: 3. Build a signed test version in GitHub Actions. 4. Complete the exact-SHA proof inventory on clean Windows 10 and Windows 11 computers, including a signed N to N+1 private update. 5. Validate and approve the redacted proof bundle while publication and the website remain disabled. -6. Bind the approved proof SHA and enable Windows in the production release workflow. +6. Bind the approved proof SHA, run id, and build-manifest digest, attest the + installed-update proof, and enable Windows in the production release + workflow. 7. Tag the approved commit. GitHub Actions builds the other platforms, promotes the exact approved Windows artifact, and creates one unpublished release. 8. Check the release, make it public, and only then enable the Windows website link. @@ -69,6 +71,14 @@ gh variable set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \ ### 2. Run the signed build in GitHub Actions +`prepare-release.yml` is the platform-neutral non-publishing dry run. Pass +`windows_proof=true` to also assert the signed Windows proof preconditions: +`ADE_WINDOWS_SIGNED_BUILD_ENABLED` must be `1` and +`ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` must not be `1`, so proof is always +collected while publication is disabled. Without that input the same workflow +stays the ordinary macOS and standalone runtime validation run and can be +dispatched in any Windows flag state. + Use the version and commit intended for the first Windows release: ```bash @@ -78,7 +88,8 @@ gh workflow run prepare-release.yml \ --repo arul28/ADE \ --ref main \ -f version="$VERSION" \ - -f target_sha="$RELEASE_SHA" + -f target_sha="$RELEASE_SHA" \ + -f windows_proof=true gh run list \ --repo arul28/ADE \ --workflow prepare-release.yml \ @@ -99,8 +110,17 @@ The run passes only when: - No GitHub Release is created. - A machine-readable proof manifest is generated for the exact checked-out SHA. -Download the `ade-win-release-v` artifact from the successful run. Its -manifest-indexed files must include: +Download the `ade-win-release-v` artifact from the successful run. + +> **Retention window.** That artifact is uploaded with `retention-days: 90`. A +> public release promotes those exact bytes, so the tag in Publish step 2 must +> happen within 90 days of this proof run. After that the artifact expires, +> promotion stops with an explicit retention error, and you must repeat steps 2 +> through 4 for the approved commit and rebind +> `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` and +> `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256`. + +Its manifest-indexed files must include: - `ADE--win-x64.exe` - `ADE--win-x64.exe.blockmap` @@ -193,17 +213,83 @@ or release-file hashes differ. ### 5. Enable Windows releases -After the recorded test results pass: +After the recorded test results pass, set both publication gates. They are +separate settings and `release-core.yml` requires both; setting only the public +flag makes the tagged release stop in its `verify` job with +`ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires approved two-version +installed-update proof.` ```bash +gh variable set ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED \ + --repo arul28/ADE --body 1 gh variable set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \ --repo arul28/ADE --body 1 ``` -This setting allows the existing release workflow to add validated Windows files to its combined draft. It does not publish a release by itself. +`ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1` is a maintainer attestation, not +a machine check. It states that an authorized Windows release maintainer has +seen and accepted the two-version installed-update proof for this exact commit: +two signed, non-public builds N and N+1 installed on clean Windows 10 x64 and +Windows 11 x64 hosts, with the private N to N+1 updater path completed +end-to-end, including timestamp and signature validation, tamper rejection, +desktop relaunch, brain service recovery, and user data preservation. + +Set it only when all of the following are true: + +- Every `pre-tag` result in the proof manifest is `pass` and the manifest state + is `proof_complete` (Step 4). +- The `publication-readiness` validator passed while both the public release and + website flags were still disabled (Step 4). +- The updater scenarios in the + [full-system scenario inventory](../development/windows-full-system-scenarios.json) + that cover the signed N to N+1 private update are recorded as `pass`. +- `ADE_WINDOWS_APPROVED_PROOF_SHA` names the same commit that produced that + proof. + +The supporting evidence lives in the redacted proof bundle assembled in Step 2 +and validated in Step 4: `windows-proof-manifest.json` at the bundle root, the +manifest-indexed release files under `artifacts/`, and the redacted GUI, log, +DB, process, IPC, and network evidence under `evidence/`. The contract for both +is [Windows release proof](../development/windows-release-proof.md). Clear this +variable back to `0` whenever the approved proof no longer describes the commit +being released. + +These settings allow the existing release workflow to add validated Windows files to its combined draft. They do not publish a release by themselves. Keep `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` unset or `0`; website readiness is not publication approval. +### Required GitHub Actions settings + +`release-core.yml` reads exactly these repository variables and secrets. Every +name below is required unless marked optional. + +| Repository variable | Required when | Meaning | +| --- | --- | --- | +| `ADE_WINDOWS_SIGNED_BUILD_ENABLED` | Any signed Windows build or proof | `1` runs the signed Windows proof job and signs the standalone `ade-win32-x64.exe`. Set in Step 1. | +| `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` | Publishing Windows files | `1` lets the tagged release promote and attach the Windows files. Set in Step 5. | +| `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED` | Publishing Windows files | `1` attests the accepted two-version installed-update proof. Set in Step 5. | +| `ADE_WINDOWS_APPROVED_PROOF_SHA` | Publishing Windows files | The approved 40-character commit SHA. Must equal the commit being released. Set in Step 4. | +| `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` | Publishing Windows files | The non-publishing proof run whose artifact is promoted. Its artifact expires 90 days after that run. Set in Step 4. | +| `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256` | Publishing Windows files | SHA-256 of the original `windows-proof-manifest.json`. Set in Step 4. | + +| Secret | Required when | Meaning | +| --- | --- | --- | +| `WINDOWS_CSC_LINK` | Signed Windows builds | Base64 PFX/P12 or a private HTTPS URL returning it. Step 1. | +| `WINDOWS_CSC_KEY_PASSWORD` | Signed Windows builds | Certificate password. Step 1. | +| `WINDOWS_SIGNING_EXPECTED_SUBJECT` | Signed Windows builds, unless the thumbprint is set | Pinned certificate Subject. Step 1. | +| `WINDOWS_SIGNING_EXPECTED_THUMBPRINT` | Signed Windows builds, unless the subject is set | Pinned certificate fingerprint. Step 1. | +| `CSC_LINK` | Every release | Existing macOS Developer ID certificate. Not used by Windows signing. | +| `CSC_KEY_PASSWORD` | Every release | Existing macOS certificate password. | +| `MACOS_DEVELOPER_ID_PROFILE_B64` | Every release | Existing macOS Developer ID provisioning profile. | +| `APPLE_API_KEY_P8` | Every release | Existing App Store Connect API key material. | +| `APPLE_API_KEY_ID` | Every release | Existing App Store Connect key id. | +| `APPLE_API_ISSUER` | Every release | Existing App Store Connect issuer id, used by notarization. | +| `ADE_POSTHOG_PROJECT_TOKEN` | Optional | Analytics token baked into packaged builds. | +| `ADE_POSTHOG_HOST` | Optional | Analytics host baked into packaged builds. | + +`VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` is not a GitHub setting. It is a Vercel +Production variable for the website and is covered in Publish step 5. + ## Publish a release Use this process for the first Windows release and every later release. @@ -216,18 +302,31 @@ Confirm: - The normal `ci-pass` check succeeded for that exact commit. - The version tag does not already exist. - The signed-build and public-release settings are `1`. +- `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED` is `1` for this exact commit. It + is required at every Windows gate in `release-core.yml`; without it the tagged + run fails in `verify` with `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires + approved two-version installed-update proof.` See + [Required GitHub Actions settings](#required-github-actions-settings). - `ADE_WINDOWS_APPROVED_PROOF_SHA` equals the approved 40-character commit SHA. -- `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` identifies the approved non-publishing run. +- `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` identifies the approved non-publishing run, + and that run finished less than 90 days ago so its artifact has not expired. - `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256` equals the recorded original build-manifest digest. - The proof manifest passed `publication-readiness` with public release and website gates still disabled. -For later releases, first set `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0`, then run -the non-publishing workflow and collect fresh exact-SHA proof for the approved -version and commit. Restore the public gate only after that proof is approved. -For the first Windows release, reuse the Step 2 result if its version and commit -are unchanged. +Confirm the current values before tagging: + +```bash +gh variable list --repo arul28/ADE | grep ADE_WINDOWS_ +``` + +For later releases, first set `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0` and +`ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=0`, then run the non-publishing +workflow and collect fresh exact-SHA proof for the approved version and commit. +Restore both gates only after that proof is approved. For the first Windows +release, reuse the Step 2 result if its version and commit are unchanged and its +artifact is still within its 90-day retention window. ```bash VERSION="" @@ -236,7 +335,8 @@ gh workflow run prepare-release.yml \ --repo arul28/ADE \ --ref main \ -f version="$VERSION" \ - -f target_sha="$RELEASE_SHA" + -f target_sha="$RELEASE_SHA" \ + -f windows_proof=true ``` Do not tag until that run succeeds for `RELEASE_SHA`. @@ -275,6 +375,17 @@ Require: - The installed app points to `arul28/ADE` for updates. - The approved proof SHA equals the release target and the downloaded Windows files match the proof manifest hashes. +- `SHA256SUMS` lists exactly `install.sh`, `install.ps1`, the four + `ade-darwin-*` and four `ade-linux-*` files, and `ade-win32-x64.exe` with its + native archive, and every listed digest matches the uploaded asset. + +The draft mixes two runs on purpose, one asset name per run. `install.sh` and +the `ade-darwin-*` and `ade-linux-*` files come from the tagged run, because the +darwin runtime binaries are notarized per run and must be byte-identical to the +copies inside the DMGs beside them. The installer, its `.blockmap`, `latest.yml`, +`install.ps1`, and the signed `ade-win32-x64` files come from the approved proof +run. `SHA256SUMS` is regenerated over that merged set, so it is not the same file +as the `SHA256SUMS` inside the proof artifact. Add the redacted draft-asset and disabled-website evidence to the proof bundle, set `draft-assets-website-explicit` to `pass`, then run the final gate: @@ -327,6 +438,12 @@ does not exist yet. - Existing macOS or standalone runtime job fails: stop the release and fix the shared workflow. - Problem found in an unpublished release: leave it unpublished, fix the source, choose a higher version, and rebuild. - Problem found after publication: hide the website link, disable Windows publication, fix and test a higher version, then re-enable Windows before tagging it. +- Promotion stops with a retention or missing-artifact error: the approved proof + run is older than its 90-day artifact retention window, its run was deleted, or + `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` points at the wrong run. Repeat One-time + setup steps 2 through 4 for the approved commit and rebind + `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` and + `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256`. Never hand-upload a replacement. - Signing certificate changes: update the signing secrets and repeat the signed installer tests. - Release workflow is rerun after publication: the workflow stops instead of replacing public files. Publish a higher version for any correction. From df6f954021557e16c2cfc68663d9833b64756653 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:46:02 -0400 Subject: [PATCH 10/27] feat(release): build and publish Windows on the release tag Windows was confined to non-publishing runs and a tagged release could only ship Windows by promoting an artifact from an earlier approved proof run. The repo owner has decided Windows should build fresh on the tag exactly like macOS, accepting that a release may publish Windows bytes that never went through a manual clean-host proof sweep, so that every desktop and CLI release publishes Windows automatically. Rename build-win-proof to build-win-release and drop its inputs.publish == false restriction, so a v* tag builds, signs, validates and publishes Windows in-run with the same shape as build-mac-release. publish-release now needs build-win-release, and with the Windows gate on a failed or skipped Windows build blocks the draft exactly as a failed macOS build does. Every machine-checkable gate is retained on the publish path: the job-level signing-secret requirement, --require-signing in run-electron-builder.mjs, --require-signed Authenticode verification in validate-win-artifacts.mjs, and the installed-product lifecycle smoke. Only the human proof-sweep gate is dropped. Two validations are re-attached so nothing is lost with promotion: the cross-platform runtime allowlist moves to the ungated publish path, where it now holds in every flag state instead of only when Windows publishes, and the Windows standalone bytes are cross-checked against the digests computed on the signing runner. A new check requires latest.yml to reference the installer actually published beside it, matching the macOS updater-feed contract. Proof collection survives as an opt-in mode: the windows_proof input is now threaded into release-core.yml and gates the proof staging, manifest generation, manifest validation and bundle upload. It reads no repository variable, so evidence can be collected before Windows is enabled and again as a regression check afterwards, which also resolves the circular gate. Remove promote-approved-win-proof and the four repository variables that only existed to bind it. Keeping them behind an opt-in would preserve a second, divergent asset-assembly path that reintroduces the cross-run provenance bug and would have to be validated forever. ADE_WINDOWS_PUBLIC_RELEASE_ENABLED is now the only Windows repository variable the release reads. Asset assembly collapses accordingly: everything is published from the current run, so the proof-internal split is gone and the ade-darwin-* provenance bug cannot reappear. The published asset names are unchanged in both flag states. Based-on: nsxdavid/ADE#999 (cherry picked from commit f79fa49101a0d971e3312a7634a094680f441c9a) --- .github/workflows/prepare-release.yml | 29 +- .github/workflows/release-core.yml | 388 +++++++----------- .../scripts/windows-release-contract.test.mjs | 142 ++++--- 3 files changed, 253 insertions(+), 306 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 76e7b8784..6bb62d4fe 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -18,10 +18,10 @@ on: type: string windows_proof: description: >- - Collect the signed Windows exact-SHA proof. Requires - ADE_WINDOWS_SIGNED_BUILD_ENABLED=1 and - ADE_WINDOWS_PUBLIC_RELEASE_ENABLED other than 1. Leave false for the - platform-neutral macOS and standalone runtime dry run. + Collect clean-host Windows proof evidence. Builds and signs Windows + even while ADE_WINDOWS_PUBLIC_RELEASE_ENABLED is off, and emits the + exact-SHA proof bundle. Leave false for the ordinary dry run, which + still builds Windows whenever the publication gate is on. required: false default: false type: boolean @@ -38,23 +38,25 @@ jobs: tag_name: ${{ steps.resolve.outputs.tag_name }} target_sha: ${{ steps.resolve.outputs.target_sha }} steps: - # Opt-in mode. Without it this workflow stays the platform-neutral dry run - # that macOS and standalone runtime releases have always used, so it stays - # runnable no matter how the Windows repository variables are set. + # Opt-in evidence collection. It never depends on a repository variable + # state, so proof can be collected before Windows is enabled and again as + # a regression check after it is. This workflow never publishes either way. - name: Prepare signed Windows proof mode if: ${{ inputs.windows_proof }} env: - BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} - PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} + WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} run: | set -euo pipefail - if [ "$BUILD_WINDOWS" != "1" ]; then - echo "::error::Signed Windows proof requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1." + if [ -z "$WINDOWS_CSC_LINK" ] || [ -z "$WINDOWS_CSC_KEY_PASSWORD" ]; then + echo "::error::Signed Windows proof requires the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD secrets." exit 1 fi - if [ "$PUBLISH_WINDOWS" = "1" ]; then - echo "::error::Signed Windows proof is non-publishing. Set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0 before running it." + if [ -z "$WINDOWS_SIGNING_EXPECTED_SUBJECT" ] && [ -z "$WINDOWS_SIGNING_EXPECTED_THUMBPRINT" ]; then + echo "::error::Signed Windows proof requires the WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT secret." exit 1 fi @@ -109,4 +111,5 @@ jobs: release_tag: ${{ needs.resolve.outputs.tag_name }} target_ref: ${{ needs.resolve.outputs.target_sha }} publish: false + windows_proof: ${{ inputs.windows_proof }} secrets: inherit diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 727498b20..300625854 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -16,6 +16,15 @@ on: required: false default: false type: boolean + windows_proof: + description: >- + Collect clean-host Windows proof evidence. Builds Windows even while + ADE_WINDOWS_PUBLIC_RELEASE_ENABLED is off and emits the exact-SHA + proof manifest bundle. Deliberate evidence collection only; it does + not gate ordinary releases. + required: false + default: false + type: boolean permissions: actions: read @@ -88,45 +97,33 @@ jobs: echo "ci-pass succeeded for $TARGET_REF: $url" + # Windows builds fresh on the tag, the same way macOS does. There is one + # maintainer-facing switch, ADE_WINDOWS_PUBLIC_RELEASE_ENABLED, plus the + # deliberate windows_proof dispatch input for clean-host evidence runs. + # Fail here, a minute in, rather than after a full Windows package build. - name: Validate Windows release configuration env: - BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} - WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} - APPROVED_PROOF_SHA: ${{ vars.ADE_WINDOWS_APPROVED_PROOF_SHA }} - APPROVED_PROOF_RUN_ID: ${{ vars.ADE_WINDOWS_APPROVED_PROOF_RUN_ID }} - APPROVED_BUILD_MANIFEST_SHA256: ${{ vars.ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256 }} + WINDOWS_PROOF: ${{ inputs.windows_proof }} + WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} + WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} run: | set -euo pipefail - if [ "$PUBLISH_WINDOWS" = "1" ] && [ "$BUILD_WINDOWS" != "1" ]; then - echo "::error::ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1." - exit 1 + if [ "$PUBLISH_WINDOWS" != "1" ] && [ "$WINDOWS_PROOF" != "true" ]; then + echo "Windows release is disabled for this run. Set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 to publish Windows assets." + exit 0 fi - if [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" != "1" ]; then - echo "::error::ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires approved two-version installed-update proof." + + if [ -z "$WINDOWS_CSC_LINK" ] || [ -z "$WINDOWS_CSC_KEY_PASSWORD" ]; then + echo "::error::Windows releases require the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD secrets." exit 1 fi - - if [ "$PUBLISH_WINDOWS" = "1" ]; then - target_sha="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" - proof_sha="$(printf '%s' "$APPROVED_PROOF_SHA" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" - if ! printf '%s' "$proof_sha" | grep -Eq '^[0-9a-f]{40}$'; then - echo "::error::Public Windows release requires ADE_WINDOWS_APPROVED_PROOF_SHA to contain the approved exact 40-character commit SHA." - exit 1 - fi - if [ "$proof_sha" != "$target_sha" ]; then - echo "::error::Approved Windows proof is for $proof_sha, but this release builds $target_sha." - exit 1 - fi - if ! printf '%s' "$APPROVED_PROOF_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then - echo "::error::Public Windows release requires ADE_WINDOWS_APPROVED_PROOF_RUN_ID to identify the immutable non-publishing workflow run." - exit 1 - fi - if ! printf '%s' "$APPROVED_BUILD_MANIFEST_SHA256" | grep -Eq '^[0-9a-f]{64}$'; then - echo "::error::Public Windows release requires ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256 to bind the approved artifact set." - exit 1 - fi + if [ -z "$WINDOWS_SIGNING_EXPECTED_SUBJECT" ] && [ -z "$WINDOWS_SIGNING_EXPECTED_THUMBPRINT" ]; then + echo "::error::Windows releases require the WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT secret to pin the approved publisher." + exit 1 fi build-mac-release: @@ -303,14 +300,18 @@ jobs: apps/desktop/release/latest-mac-${{ matrix.arch }}.yml if-no-files-found: error - build-win-proof: - if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && inputs.publish == false }} + build-win-release: + # Same shape as build-mac-release: the tagged commit is built, signed and + # validated in-run, and its output is published by publish-release. The + # windows_proof input additionally builds Windows while the publication gate + # is still off, so clean-host evidence can be collected deliberately. + if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' || inputs.windows_proof }} needs: - verify - build-runtime-binaries runs-on: windows-latest concurrency: - group: release-${{ inputs.release_tag }}-win-proof + group: release-${{ inputs.release_tag }}-win-x64 cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -390,7 +391,24 @@ jobs: if ($installers.Count -ne 1) { throw "Expected exactly one signed Windows installer, found $($installers.Count)." } & apps/desktop/scripts/windows-installed-product-smoke.ps1 -InstallerPath $installers[0].FullName + - name: Upload validated Windows release artifacts + uses: actions/upload-artifact@v4 + with: + name: ade-win-release-${{ inputs.release_tag }} + path: | + apps/desktop/release/ADE-*-win-x64.exe + apps/desktop/release/ADE-*-win-x64.exe.blockmap + apps/desktop/release/latest.yml + if-no-files-found: error + + # Everything below is deliberate clean-host evidence collection. It builds + # the redacted proof bundle and its exact-SHA manifest, and never gates an + # ordinary release. The machine-checkable Windows gates all ran above: + # the job-level signing-secret requirement, --require-signing in + # run-electron-builder.mjs, --require-signed Authenticode verification in + # validate-win-artifacts.mjs, and the installed-product lifecycle smoke. - name: Stage standalone runtime proof assets + if: ${{ inputs.windows_proof }} shell: pwsh run: | $releaseDir = "apps/desktop/release" @@ -437,6 +455,7 @@ jobs: ) - name: Generate exact-SHA Windows proof manifest + if: ${{ inputs.windows_proof }} shell: pwsh run: >- node apps/desktop/scripts/windows-proof-manifest.mjs create @@ -451,6 +470,7 @@ jobs: --workflow-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - name: Validate exact-SHA Windows build proof + if: ${{ inputs.windows_proof }} shell: pwsh run: >- node apps/desktop/scripts/windows-proof-manifest.mjs validate @@ -461,14 +481,14 @@ jobs: --expected-run-id "${{ github.run_id }}" --artifact-root apps/desktop/release - - name: Upload validated Windows proof artifact + - name: Upload validated Windows proof bundle + if: ${{ inputs.windows_proof }} uses: actions/upload-artifact@v4 with: - name: ade-win-release-${{ inputs.release_tag }} - # A public release promotes these exact bytes from this run, so the - # artifact has to outlive approval. Pinned instead of inherited: a - # lowered repository or organization default would silently shorten - # the window in which the approved proof can still be released. + name: ade-win-proof-${{ inputs.release_tag }} + # Clean-host evidence is reviewed by a human days or weeks after the + # run. Pinned instead of inherited so a lowered repository or + # organization default cannot expire the bundle mid-review. # Keep this in sync with docs/playbooks/windows-signed-release.md. retention-days: 90 path: | @@ -491,122 +511,6 @@ jobs: apps/desktop/release/windows-proof-manifest.json if-no-files-found: error - promote-approved-win-proof: - if: ${{ inputs.publish && vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' }} - needs: verify - runs-on: windows-latest - concurrency: - group: release-${{ inputs.release_tag }}-win-promotion - cancel-in-progress: true - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.target_ref }} - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Confirm approved Windows proof artifact is still retained - shell: bash - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - APPROVED_PROOF_RUN_ID: ${{ vars.ADE_WINDOWS_APPROVED_PROOF_RUN_ID }} - ARTIFACT_NAME: ade-win-release-${{ inputs.release_tag }} - run: | - set -uo pipefail - - if ! status="$( - gh api "repos/$GH_REPO/actions/runs/$APPROVED_PROOF_RUN_ID/artifacts" \ - --method GET \ - -H "Accept: application/vnd.github+json" \ - -f per_page=100 \ - --jq ' - [.artifacts[] | select(.name == env.ARTIFACT_NAME)] as $named - | if ($named | length) == 0 then "missing" - elif ([$named[] | select(.expired == false)] | length) == 0 then "expired" - else "available" end - ' - )"; then - status="unreadable" - fi - - recovery="Re-run prepare-release.yml with windows_proof=true for the approved commit while ADE_WINDOWS_PUBLIC_RELEASE_ENABLED is not 1, re-approve the proof, then update ADE_WINDOWS_APPROVED_PROOF_RUN_ID and ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256." - - case "$status" in - available) - echo "Approved Windows proof artifact $ARTIFACT_NAME from run $APPROVED_PROOF_RUN_ID is still retained." - ;; - expired) - echo "::error::Approved Windows proof artifact $ARTIFACT_NAME from run $APPROVED_PROOF_RUN_ID has passed its 90-day retention window and can no longer be downloaded. $recovery" - exit 1 - ;; - missing) - echo "::error::Approved Windows proof run $APPROVED_PROOF_RUN_ID has no artifact named $ARTIFACT_NAME. ADE_WINDOWS_APPROVED_PROOF_RUN_ID may point at the wrong run, or the release tag may differ from the proven one. $recovery" - exit 1 - ;; - *) - echo "::error::Could not read artifacts for approved Windows proof run $APPROVED_PROOF_RUN_ID. Confirm ADE_WINDOWS_APPROVED_PROOF_RUN_ID names a run in $GH_REPO that has not been deleted. $recovery" - exit 1 - ;; - esac - - - name: Download approved immutable Windows proof artifact - uses: actions/download-artifact@v4 - with: - name: ade-win-release-${{ inputs.release_tag }} - path: apps/desktop/release - github-token: ${{ github.token }} - run-id: ${{ vars.ADE_WINDOWS_APPROVED_PROOF_RUN_ID }} - - - name: Validate approved Windows artifact identity - shell: pwsh - env: - APPROVED_BUILD_MANIFEST_SHA256: ${{ vars.ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256 }} - run: | - $manifestPath = "apps/desktop/release/windows-proof-manifest.json" - $actualManifestSha = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualManifestSha -ne $env:APPROVED_BUILD_MANIFEST_SHA256) { - throw "Approved build manifest digest mismatch: expected $env:APPROVED_BUILD_MANIFEST_SHA256, received $actualManifestSha." - } - node apps/desktop/scripts/windows-proof-manifest.mjs validate ` - --manifest $manifestPath ` - --phase build ` - --expected-sha "${{ inputs.target_ref }}" ` - --expected-tag "${{ inputs.release_tag }}" ` - --expected-run-id "${{ vars.ADE_WINDOWS_APPROVED_PROOF_RUN_ID }}" ` - --artifact-root apps/desktop/release - - - name: Upload approved Windows artifacts for draft assembly - uses: actions/upload-artifact@v4 - with: - name: ade-win-release-${{ inputs.release_tag }} - # Re-published copy of the approved proof, consumed by publish-release - # in this same run. Pinned so a shortened default cannot expire it - # between promotion and draft assembly. - retention-days: 90 - path: | - apps/desktop/release/ADE-*-win-x64.exe - apps/desktop/release/ADE-*-win-x64.exe.blockmap - apps/desktop/release/ade-darwin-arm64 - apps/desktop/release/ade-darwin-arm64.native.tar.gz - apps/desktop/release/ade-darwin-x64 - apps/desktop/release/ade-darwin-x64.native.tar.gz - apps/desktop/release/ade-linux-arm64 - apps/desktop/release/ade-linux-arm64.native.tar.gz - apps/desktop/release/ade-linux-x64 - apps/desktop/release/ade-linux-x64.native.tar.gz - apps/desktop/release/ade-win32-x64.exe - apps/desktop/release/ade-win32-x64.native.tar.gz - apps/desktop/release/install.sh - apps/desktop/release/install.ps1 - apps/desktop/release/SHA256SUMS - apps/desktop/release/latest.yml - apps/desktop/release/windows-proof-manifest.json - if-no-files-found: error - build-runtime-binaries: needs: verify strategy: @@ -668,7 +572,7 @@ jobs: run: cd apps/ade-cli && npm run build:static -- --target ${{ matrix.target }} - name: Sign and validate standalone Windows runtime - if: ${{ matrix.target == 'win32-x64' && vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' }} + if: ${{ matrix.target == 'win32-x64' && (vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' || inputs.windows_proof) }} shell: pwsh env: WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} @@ -751,8 +655,8 @@ jobs: exit 1 fi - - name: Assemble signed Windows standalone proof bundle - if: ${{ matrix.target == 'win32-x64' && vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' }} + - name: Assemble signed Windows standalone runtime bundle + if: ${{ matrix.target == 'win32-x64' && (vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' || inputs.windows_proof) }} shell: bash run: | set -euo pipefail @@ -777,6 +681,10 @@ jobs: compression-level: 0 publish-release: + # Windows is a first-class release platform: when its gate is on, a failed + # or skipped Windows build blocks the draft exactly as a failed macOS build + # does. always() is still needed so the job evaluates when build-win-release + # is legitimately skipped with the gate off. if: >- ${{ always() @@ -785,16 +693,13 @@ jobs: && needs.build-mac-release.result == 'success' && ( vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1' - || ( - vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' - && needs.promote-approved-win-proof.result == 'success' - ) + || needs.build-win-release.result == 'success' ) }} needs: - build-runtime-binaries - build-mac-release - - promote-approved-win-proof + - build-win-release permissions: actions: read contents: write @@ -831,7 +736,7 @@ jobs: rm -f release-assets/mac/latest-mac-arm64.yml release-assets/mac/latest-mac-x64.yml - name: Download Windows release artifacts - if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' }} + if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} uses: actions/download-artifact@v4 with: name: ade-win-release-${{ inputs.release_tag }} @@ -890,6 +795,31 @@ jobs: exit 1 fi + # Cross-platform runtime allowlist. Every ade-* file in the runtime + # download must be one of the ten this release is allowed to carry, in + # every flag state, so an extra or missing sidecar can never reach the + # draft. All ten are produced by build-runtime-binaries in this run. + runtime_files=( + ade-darwin-arm64 + ade-darwin-arm64.native.tar.gz + ade-darwin-x64 + ade-darwin-x64.native.tar.gz + ade-linux-arm64 + ade-linux-arm64.native.tar.gz + ade-linux-x64 + ade-linux-x64.native.tar.gz + ade-win32-x64.exe + ade-win32-x64.native.tar.gz + ) + mapfile -t actual_runtime_files < <( + find release-assets/runtime -maxdepth 1 -type f -name 'ade-*' -printf '%f\n' | LC_ALL=C sort + ) + mapfile -t expected_runtime_files < <(printf '%s\n' "${runtime_files[@]}" | LC_ALL=C sort) + if ! diff -u <(printf '%s\n' "${expected_runtime_files[@]}") <(printf '%s\n' "${actual_runtime_files[@]}"); then + echo "::error::Runtime artifacts contain an unauthorized or missing entry." + exit 1 + fi + for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do binary="release-assets/runtime/ade-$target" require_file "$binary" "ADE runtime binary for $target" @@ -903,22 +833,13 @@ jobs: done - name: Validate gated Windows publish asset manifest - if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' }} + if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} run: | set -euo pipefail shopt -s nullglob - runtime_files=( - ade-darwin-arm64 - ade-darwin-arm64.native.tar.gz - ade-darwin-x64 - ade-darwin-x64.native.tar.gz - ade-linux-arm64 - ade-linux-arm64.native.tar.gz - ade-linux-x64 - ade-linux-x64.native.tar.gz - ade-win32-x64.exe - ade-win32-x64.native.tar.gz - ) + + # Desktop installer set, built and signed by build-win-release in this + # same run. installers=(release-assets/win/ADE-*-win-x64.exe) blockmaps=(release-assets/win/ADE-*-win-x64.exe.blockmap) if [ "${#installers[@]}" -ne 1 ] || [ "${#blockmaps[@]}" -ne 1 ]; then @@ -928,57 +849,67 @@ jobs: test -s "${installers[0]}" test -s "${blockmaps[0]}" test -s release-assets/win/latest.yml - test -s release-assets/win/install.sh - test -s release-assets/win/install.ps1 - test -s release-assets/win/ade-win32-x64.exe - test -s release-assets/win/ade-win32-x64.native.tar.gz - test -s release-assets/win/SHA256SUMS - mapfile -t actual_runtime_files < <( - find release-assets/win -maxdepth 1 -type f -name 'ade-*' -printf '%f\n' | LC_ALL=C sort - ) - mapfile -t expected_runtime_files < <(printf '%s\n' "${runtime_files[@]}" | LC_ALL=C sort) - if ! diff -u <(printf '%s\n' "${expected_runtime_files[@]}") <(printf '%s\n' "${actual_runtime_files[@]}"); then - echo "::error::Windows proof contains an unauthorized or missing runtime artifact." + + # Updater feed correctness: latest.yml must name the installer that is + # actually being published beside it, the same contract the merged + # latest-mac.yml carries for macOS. + installer_name="$(basename "${installers[0]}")" + if ! grep -Fq "$installer_name" release-assets/win/latest.yml; then + echo "::error::release-assets/win/latest.yml does not reference the published installer $installer_name." exit 1 fi - if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' release-assets/win/SHA256SUMS; then - echo "::error::Windows proof checksum manifest has an invalid entry." + + # Standalone Windows runtime, built and signed by build-runtime-binaries + # in this same run. + test -s release-assets/runtime/install.ps1 + test -s release-assets/runtime/ade-win32-x64.exe + test -s release-assets/runtime/ade-win32-x64.native.tar.gz + # Cross-check the Windows standalone bytes against the digests computed + # on the signing runner, so a corrupted artifact transfer cannot reach + # the draft. That manifest is written by Git Bash on windows-latest, + # where sha256sum reads in binary mode and marks each name with a + # leading '*'; normalize it before parsing. Text and binary mode are + # identical here on Linux, so the normalized form verifies correctly. + test -s release-assets/runtime/SHA256SUMS + windows_sums="$RUNNER_TEMP/windows-standalone-SHA256SUMS" + sed 's/^\([0-9a-f]\{64\}\) [ *]/\1 /' release-assets/runtime/SHA256SUMS > "$windows_sums" + if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' "$windows_sums"; then + echo "::error::Windows standalone checksum manifest has an invalid entry." exit 1 fi - mapfile -t actual_checksum_files < <(awk '{ print $2 }' release-assets/win/SHA256SUMS | LC_ALL=C sort) + mapfile -t actual_checksum_files < <(awk '{ print $2 }' "$windows_sums" | LC_ALL=C sort) mapfile -t expected_checksum_files < <( - printf '%s\n' install.sh install.ps1 "${runtime_files[@]}" | LC_ALL=C sort + printf '%s\n' install.ps1 ade-win32-x64.exe ade-win32-x64.native.tar.gz | LC_ALL=C sort ) if ! diff -u <(printf '%s\n' "${expected_checksum_files[@]}") <(printf '%s\n' "${actual_checksum_files[@]}"); then - echo "::error::Windows proof checksum manifest does not name the exact authorized runtime set." + echo "::error::Windows standalone checksum manifest does not name the exact authorized Windows runtime set." exit 1 fi - (cd release-assets/win && sha256sum -c SHA256SUMS) + (cd release-assets/runtime && sha256sum -c "$windows_sums") + - name: Create or update draft GitHub release env: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ inputs.release_tag }} TARGET_REF: ${{ inputs.target_ref }} GH_REPO: ${{ github.repository }} - BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} - WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} run: | set -euo pipefail shopt -s nullglob - # The per-arch macOS zips + latest-mac.yml are what electron-updater - # consumes; DMGs are the human downloads. Mac blockmaps stay omitted. - # Only an exact-SHA-approved Windows proof allows the public gate to - # add the signed installer, blockmap, and latest.yml as one set. + # Every asset comes from this run. The per-arch macOS zips + + # latest-mac.yml are what electron-updater consumes; DMGs are the human + # downloads. Mac blockmaps stay omitted. The Windows installer, + # blockmap and latest.yml are the electron-updater equivalents. base_files=( release-assets/mac/*.dmg release-assets/mac/*.zip release-assets/mac/latest-mac.yml ) - # Cross-platform standalone assets always come from this run. The - # darwin runtime binaries are notarized per run, so only this run's - # copies are byte-identical to the ones inside the DMGs published - # beside them. + # Cross-platform standalone runtime. These are notarized or signed by + # the build-runtime-binaries jobs of this same run, which is what keeps + # them byte-identical to the copies bundled inside the desktop + # installers published beside them. runtime_files=( release-assets/runtime/install.sh release-assets/runtime/ade-darwin-* @@ -987,54 +918,29 @@ jobs: files=("${base_files[@]}" "${runtime_files[@]}") checksum_files=("${runtime_files[@]}") - # This repository variable stays disabled until the signed installer - # passes the clean-host release checks. - if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then - # The approved proof bundle also carries the cross-platform runtime - # set and its own checksum manifest. Those bytes belong to the older - # proof run and are superseded by the current run's notarized - # copies, so they stay proof-internal and are never published. - proof_internal_files=( - release-assets/win/install.sh - release-assets/win/SHA256SUMS - release-assets/win/ade-darwin-arm64 - release-assets/win/ade-darwin-arm64.native.tar.gz - release-assets/win/ade-darwin-x64 - release-assets/win/ade-darwin-x64.native.tar.gz - release-assets/win/ade-linux-arm64 - release-assets/win/ade-linux-arm64.native.tar.gz - release-assets/win/ade-linux-x64 - release-assets/win/ade-linux-x64.native.tar.gz - ) - for proof_internal_file in "${proof_internal_files[@]}"; do - if [ ! -s "$proof_internal_file" ]; then - echo "::error::Approved Windows proof is missing $proof_internal_file." - exit 1 - fi - done - # Only the genuinely Windows-specific members of the approved proof - # are published, so no asset name is served by two different runs. + # One switch decides whether this release carries Windows at all. + if [ "$PUBLISH_WINDOWS" = "1" ]; then windows_files=( release-assets/win/ADE-*-win-x64.exe release-assets/win/ADE-*-win-x64.exe.blockmap release-assets/win/latest.yml - release-assets/win/install.ps1 - release-assets/win/ade-win32-x64.exe - release-assets/win/ade-win32-x64.native.tar.gz + release-assets/runtime/install.ps1 + release-assets/runtime/ade-win32-x64.exe + release-assets/runtime/ade-win32-x64.native.tar.gz ) files=("${files[@]}" "${windows_files[@]}") checksum_files+=( - release-assets/win/install.ps1 - release-assets/win/ade-win32-x64.exe - release-assets/win/ade-win32-x64.native.tar.gz + release-assets/runtime/install.ps1 + release-assets/runtime/ade-win32-x64.exe + release-assets/runtime/ade-win32-x64.native.tar.gz ) fi # One checksum manifest over exactly the standalone assets uploaded - # below, regenerated because the published set merges this run's - # notarized cross-platform binaries with the approved proof run's - # signed Windows binaries. install-runtime.sh and install-runtime.ps1 - # resolve their own platform's entries from this file. + # below, regenerated so the digests always describe the bytes actually + # published rather than any per-job manifest staged upstream. + # install-runtime.sh and install-runtime.ps1 resolve their own + # platform's entries from this file. mkdir -p release-assets/publish published_checksums=release-assets/publish/SHA256SUMS for checksum_file in "${checksum_files[@]}"; do diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index cc4789b2f..3cb9b8217 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -134,8 +134,8 @@ test("public Windows packaging fails closed on Authenticode signing", () => { assert.match(pkg.scripts["dist:win:signed"], /validate:win:release:signed/); assert.match(pkg.scripts["package:win:signed"], /--require-signing/); - const windowsRelease = jobBlock(releaseWorkflow, "build-win-proof", "promote-approved-win-proof"); - assert.match(windowsRelease, /ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1'/); + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); + assert.match(windowsRelease, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof/); assert.match(windowsRelease, /npm run dist:win:signed/); assert.match(windowsRelease, /ADE_RELEASE_REPOSITORY:\s*\$\{\{ github\.repository \}\}/); assert.match(windowsRelease, /WINDOWS_CSC_LINK/); @@ -252,7 +252,7 @@ test("standalone Windows runtime signing uses only canonical credentials and val ); assert.match(runtimeBuild, /target: win32-x64[\s\S]*os: windows-latest[\s\S]*binary: ade-win32-x64\.exe/); assert.match(windowsSignStep, /matrix\.target == 'win32-x64'/); - assert.match(windowsSignStep, /ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1'/); + assert.match(windowsSignStep, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof/); assert.match(windowsSignStep, /WINDOWS_CSC_LINK: \$\{\{ secrets\.WINDOWS_CSC_LINK \}\}/); assert.match(windowsSignStep, /WINDOWS_CSC_KEY_PASSWORD: \$\{\{ secrets\.WINDOWS_CSC_KEY_PASSWORD \}\}/); assert.match(windowsSignStep, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); @@ -268,29 +268,29 @@ test("standalone Windows runtime signing uses only canonical credentials and val assert.doesNotMatch(windowsRuntimeSigner, /Write-Output.*(?:certificateSource|certificatePassword|expectedSubject|expectedThumbprint)/); }); -test("standalone Windows release assets remain behind every publication and proof gate", () => { +test("standalone Windows release assets remain behind the publication gate", () => { const publish = jobBlock(releaseWorkflow, "publish-release", null); const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "publish-release"); const installer = fs.readFileSync( path.join(repoRoot, "apps", "ade-cli", "scripts", "install-runtime.ps1"), "utf8", ); - const releaseFiles = publish.slice(publish.indexOf("base_files=("), publish.indexOf("if [ \"$BUILD_WINDOWS\"", publish.indexOf("base_files=("))); + const releaseFiles = publish.slice(publish.indexOf("base_files=("), publish.indexOf("if [ \"$PUBLISH_WINDOWS\"", publish.indexOf("base_files=("))); assert.doesNotMatch(releaseFiles, /install\.ps1|ade-win32-x64/); - assert.doesNotMatch(publish, /runtime_assets\+=\(install\.ps1|release-assets\/runtime\/ade-win32-x64/); assert.match(publish, /installers=\(release-assets\/win\/ADE-\*-win-x64\.exe\)/); - assert.match(publish, /test -s release-assets\/win\/install\.ps1/); - assert.match(publish, /test -s release-assets\/win\/ade-win32-x64\.exe/); - assert.match(publish, /test -s release-assets\/win\/ade-win32-x64\.native\.tar\.gz/); - assert.match(publish, /\(cd release-assets\/win && sha256sum -c SHA256SUMS\)/); - assert.match(publish, /release-assets\/win\/install\.ps1[\s\S]*release-assets\/win\/SHA256SUMS[\s\S]*release-assets\/win\/ade-darwin-arm64[\s\S]*release-assets\/win\/ade-win32-x64\.exe/); - assert.doesNotMatch(publish, /release-assets\/win\/ade-\*/); - assert.match(publish, /\[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \] && \[ "\$WINDOWS_UPDATE_PROOF_APPROVED" = "1" \]/); + assert.match(publish, /test -s release-assets\/runtime\/install\.ps1/); + assert.match(publish, /test -s release-assets\/runtime\/ade-win32-x64\.exe/); + assert.match(publish, /test -s release-assets\/runtime\/ade-win32-x64\.native\.tar\.gz/); + assert.match(publish, /\(cd release-assets\/runtime && sha256sum -c SHA256SUMS\)/); + // Windows standalone assets are named only inside the publication-gated + // branch, and every published asset is sourced from this run. + assert.match(publish, /if \[ "\$PUBLISH_WINDOWS" = "1" \]; then[\s\S]*release-assets\/runtime\/install\.ps1[\s\S]*release-assets\/runtime\/ade-win32-x64\.exe/); + assert.doesNotMatch(publish, /release-assets\/win\/ade-|release-assets\/win\/install\.|release-assets\/win\/SHA256SUMS/); assert.match(publish, /for asset in "\$\{existing_assets\[@\]\}"; do[\s\S]*gh release delete-asset/); assert.match(publish, /gh release delete-asset "\$TAG_NAME" "\$asset" --repo "\$GH_REPO" --yes/); assert.match(publish, /Draft release asset inventory differs from the exact validated set/); - assert.match(runtimeBuild, /name: Assemble signed Windows standalone proof bundle/); - assert.match(runtimeBuild, /matrix\.target == 'win32-x64' && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1'/); + assert.match(runtimeBuild, /name: Assemble signed Windows standalone runtime bundle/); + assert.match(runtimeBuild, /matrix\.target == 'win32-x64' && \(vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof\)/); assert.match(runtimeBuild, /sha256sum install\.ps1 ade-win32-x64\.exe ade-win32-x64\.native\.tar\.gz/); assert.match(runtimeBuild, /apps\/ade-cli\/dist-static\/install\.ps1/); assert.match(runtimeBuild, /apps\/ade-cli\/dist-static\/SHA256SUMS/); @@ -313,22 +313,20 @@ test("Windows release assets are validated and published as one release set", () assert.match(workflowHeader, /contents: read/); assert.doesNotMatch(workflowHeader, /contents: write/); assert.match(releaseTriggerWorkflow, /permissions:\s*\n\s+actions: read\s*\n\s+checks: read\s*\n\s+contents: write/); - assert.match(publish, /- promote-approved-win-proof/); + assert.match(publish, /- build-win-release/); assert.match(publish, /permissions:\s*\n\s+actions: read\s*\n\s+contents: write/); assert.match(publish, /name: ade-win-release-/); - assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'[\s\S]*needs\.promote-approved-win-proof\.result == 'success'/); - assert.match(verify, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1/); - assert.match(verify, /ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED/); - assert.match(verify, /requires approved two-version installed-update proof/); - assert.match(verify, /ADE_WINDOWS_APPROVED_PROOF_SHA/); - assert.match(verify, /ADE_WINDOWS_APPROVED_PROOF_RUN_ID/); - assert.match(verify, /ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256/); - assert.match(verify, /Approved Windows proof is for \$proof_sha, but this release builds \$target_sha/); - assert.match(publish, /ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); - assert.match(publish, /ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1'/); - assert.match(publish, /BUILD_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED \}\}/); + // Windows is a first-class platform: with its gate on, a failed or skipped + // Windows build blocks the draft exactly as a failed macOS build does. + assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'[\s\S]*needs\.build-win-release\.result == 'success'/); + // verify fails fast on missing signing material instead of on stale proof + // bindings, which no longer exist. + assert.match(verify, /Windows releases require the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD secrets/); + assert.match(verify, /WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT secret to pin the approved publisher/); + assert.match(verify, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); + assert.match(publish, /if: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \}\}/); assert.match(publish, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); - assert.match(publish, /if \[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \] && \[ "\$WINDOWS_UPDATE_PROOF_APPROVED" = "1" \]; then/); + assert.match(publish, /if \[ "\$PUBLISH_WINDOWS" = "1" \]; then/); assert.match(publish, /release-assets\/win\/ADE-\*-win-x64\.exe/); assert.match(publish, /release-assets\/win\/ADE-\*-win-x64\.exe\.blockmap/); assert.match(publish, /release-assets\/win\/latest\.yml/); @@ -400,13 +398,33 @@ test("release preflight validates the exact approved commit", () => { assert.match(verify, /Release tag \$RELEASE_TAG resolves to \$tag_sha, not approved target \$target_sha/); }); -test("signed Windows proof workflow is non-publishing and emits an exact-SHA manifest", () => { - const windowsRelease = jobBlock(releaseWorkflow, "build-win-proof", "promote-approved-win-proof"); +test("Windows proof collection is opt-in, non-publishing, and emits an exact-SHA manifest", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); assert.match(prepareWorkflow, /name: Prepare signed Windows proof/); - assert.match(prepareWorkflow, /Signed Windows proof requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1/); - assert.match(prepareWorkflow, /Signed Windows proof is non-publishing/); + assert.match(prepareWorkflow, /Signed Windows proof requires the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD secrets/); + assert.match(prepareWorkflow, /windows_proof: \$\{\{ inputs\.windows_proof \}\}/); assert.match(prepareWorkflow, /publish: false/); assert.doesNotMatch(prepareWorkflow, /contents: write/); + // Proof collection never depends on a repository variable state, so it can + // run before Windows is enabled and again as a regression check after. + assert.doesNotMatch(prepareWorkflow, /vars\.ADE_WINDOWS_/); + // The proof bundle is built only under the explicit input; ordinary releases + // build, sign and validate Windows without it. + for (const proofStep of [ + "Stage standalone runtime proof assets", + "Generate exact-SHA Windows proof manifest", + "Validate exact-SHA Windows build proof", + "Upload validated Windows proof bundle", + ]) { + const stepIndex = windowsRelease.indexOf(`- name: ${proofStep}`); + assert.notEqual(stepIndex, -1, `expected proof step ${proofStep}`); + assert.match( + windowsRelease.slice(stepIndex, stepIndex + 400), + /if: \$\{\{ inputs\.windows_proof \}\}/, + `${proofStep} must be gated on the windows_proof input`, + ); + } + assert.match(windowsRelease, /name: ade-win-proof-\$\{\{ inputs\.release_tag \}\}/); assert.match(windowsRelease, /windows-proof-manifest\.mjs create/); assert.match(windowsRelease, /--target-sha "\$\{\{ inputs\.target_ref \}\}"/); assert.match(windowsRelease, /windows-proof-manifest\.mjs validate/); @@ -422,30 +440,44 @@ test("signed Windows proof workflow is non-publishing and emits an exact-SHA man assert.match(windowsRelease, /apps\/desktop\/release\/windows-proof-manifest\.json/); }); -test("public Windows release promotes the approved immutable proof artifact", () => { - const windowsRelease = jobBlock(releaseWorkflow, "promote-approved-win-proof", "build-runtime-binaries"); - assert.match(windowsRelease, /name: Download approved immutable Windows proof artifact/); - assert.match(windowsRelease, /run-id: \$\{\{ vars\.ADE_WINDOWS_APPROVED_PROOF_RUN_ID \}\}/); - assert.match(windowsRelease, /name: Validate approved Windows artifact identity/); - assert.match(windowsRelease, /Get-FileHash -LiteralPath \$manifestPath -Algorithm SHA256/); - assert.match(windowsRelease, /APPROVED_BUILD_MANIFEST_SHA256/); - assert.match(windowsRelease, /--expected-run-id "\$\{\{ vars\.ADE_WINDOWS_APPROVED_PROOF_RUN_ID \}\}"/); - assert.match(windowsRelease, /apps\/desktop\/release\/ade-darwin-arm64/); - assert.match(windowsRelease, /apps\/desktop\/release\/ade-win32-x64\.exe/); - assert.doesNotMatch(windowsRelease, /apps\/desktop\/release\/ade-\*/); - assert.match(windowsRelease, /apps\/desktop\/release\/install\.ps1/); - assert.match(windowsRelease, /apps\/desktop\/release\/SHA256SUMS/); - assert.match(windowsRelease, /inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars\.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1'/); +test("Windows builds fresh on the release tag with no approved-proof promotion", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); + // The desktop Windows build is no longer confined to non-publishing runs, so + // a v* tag builds, signs, validates and publishes Windows in-run. + assert.doesNotMatch(windowsRelease, /inputs\.publish == false/); + assert.match(windowsRelease, /runs-on: windows-latest/); + assert.match(windowsRelease, /- verify\s*\n\s+- build-runtime-binaries/); + assert.match(windowsRelease, /name: Upload validated Windows release artifacts/); + assert.match(windowsRelease, /name: ade-win-release-\$\{\{ inputs\.release_tag \}\}/); + // The published artifact carries the installer set only; standalone runtime + // files are published from build-runtime-binaries instead. + assert.doesNotMatch( + windowsRelease.slice( + windowsRelease.indexOf("- name: Upload validated Windows release artifacts"), + windowsRelease.indexOf("- name: Stage standalone runtime proof assets"), + ), + /ade-darwin-|ade-linux-|install\.sh/, + ); + // Promotion of a previously approved proof run is gone, along with every + // repository variable that only existed to bind it. + assert.equal(releaseWorkflow.includes("promote-approved-win-proof"), false); + assert.doesNotMatch( + releaseWorkflow, + /ADE_WINDOWS_APPROVED_PROOF_SHA|ADE_WINDOWS_APPROVED_PROOF_RUN_ID|ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256|ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED|ADE_WINDOWS_SIGNED_BUILD_ENABLED/, + ); }); test("Windows proof and draft assembly enforce exact runtime and remote asset inventories", () => { - const windowsRelease = jobBlock(releaseWorkflow, "build-win-proof", "promote-approved-win-proof"); + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); const publish = jobBlock(releaseWorkflow, "publish-release", null); assert.match(windowsRelease, /\$unexpectedRuntimeFiles = @\(\$actualRuntimeFiles \| Where-Object \{ \$_ -notin \$runtimeFiles \}\)/); assert.match(windowsRelease, /Runtime artifact inventory mismatch/); assert.doesNotMatch(windowsRelease, /Get-ChildItem[^\n]+-Filter "ade-\*"[^\n]+ForEach-Object \{/); - assert.match(publish, /Windows proof contains an unauthorized or missing runtime artifact/); - assert.match(publish, /Windows proof checksum manifest does not name the exact authorized runtime set/); + // The cross-platform runtime allowlist now guards the ungated publish path, + // so it holds in every flag state rather than only when Windows publishes. + assert.match(publish, /Runtime artifacts contain an unauthorized or missing entry/); + assert.match(publish, /Windows standalone checksum manifest does not name the exact authorized Windows runtime set/); + assert.match(publish, /latest\.yml does not reference the published installer/); assert.match(publish, /gh api "repos\/\$GH_REPO\/commits\/\$TAG_NAME" --jq '\.sha'/); assert.match(publish, /Existing draft tag \$TAG_NAME resolves to \$release_tag_target, not approved target \$approved_target/); assert.match(publish, /Draft release tag \$TAG_NAME resolves to \$final_tag_target, not approved target \$approved_target/); @@ -455,11 +487,17 @@ test("Windows proof and draft assembly enforce exact runtime and remote asset in assert.match(publish, /gh release view "\$TAG_NAME" --repo "\$GH_REPO" --json assets --jq '\.assets\[\]\.name'/); }); -test("normal public workflow skips Windows promotion while its public gate is disabled", () => { - const promotion = jobBlock(releaseWorkflow, "promote-approved-win-proof", "build-runtime-binaries"); - assert.match(promotion, /if: \$\{\{ inputs\.publish && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars\.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' \}\}/); +test("one repository variable decides whether a release carries Windows", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); const publish = jobBlock(releaseWorkflow, "publish-release", null); + // Exactly one maintainer-facing switch, matching the macOS bar: secrets are + // provisioned once, the gate is flipped once, then tags just work. + assert.match(windowsRelease, /if: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof \}\}/); assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'/); + const windowsVariables = new Set( + (releaseWorkflow.match(/vars\.ADE_WINDOWS_[A-Z0-9_]+/g) ?? []).map((entry) => entry.slice("vars.".length)), + ); + assert.deepEqual([...windowsVariables], ["ADE_WINDOWS_PUBLIC_RELEASE_ENABLED"]); }); test("pull requests build and smoke an unsigned Windows installer", () => { From 96b24c5985e9ff442465cfc858d00d676bc12e05 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:47:46 -0400 Subject: [PATCH 11/27] docs(release): make the release skill and AGENTS Windows-aware The /release skill grepped clean for windows, win-x64 and latest.yml. Its updater-correctness list and its required-assets verification were macOS-only, so it would happily verify and publish a release containing zero Windows assets. AGENTS.md described a release as producing only .dmg, .zip, blockmap and latest-mac.yml. Add the Windows assets to both the updater-correctness list and the required-assets inventory, add the latest.yml updater-feed check, and add a Phase 0 preflight that reads ADE_WINDOWS_PUBLIC_RELEASE_ENABLED and records the expected platform matrix so later phases know what the draft must contain. Add a gate/asset agreement check that fails in both directions: gate on with no Windows assets means the Windows build silently did not contribute, gate off with Windows assets means they reached a release that was not supposed to carry them. Both keep the release draft. Also correct the standalone runtime assets, which the required-assets list had never covered on any platform, and update the expected workflow shape, recovery rules and final report for the in-run Windows build. Based-on: nsxdavid/ADE#999 (cherry picked from commit 78762d2119eb4d9a9acb45dbfdc51e27d94b2acb) --- .agents/skills/release/SKILL.md | 117 +++++++++++++++++++++++++++++--- AGENTS.md | 2 +- 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 917f77dc2..dbf37d460 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -11,9 +11,10 @@ ship a TestFlight build. This is a **GitHub desktop + local ASC iOS release flow**. Desktop releases must use the repository GitHub Actions release workflow so macOS updater assets -are produced reproducibly as per-arch ZIP/DMG artifacts. This Mac may still run -checks, create release docs/tags, monitor and recover the workflow, and build -and upload iOS/TestFlight releases through ASC. +are produced reproducibly as per-arch ZIP/DMG artifacts, and so the signed +Windows installer is produced on a Windows runner this Mac cannot provide. This +Mac may still run checks, create release docs/tags, monitor and recover the +workflow, and build and upload iOS/TestFlight releases through ASC. A **preflight** is a cheap check that runs before expensive build/upload work. Use preflights to catch release blockers while fixes can still be committed @@ -42,7 +43,13 @@ without burning a notarization, TestFlight upload, or build number. crash Squirrel.Mac during in-app update. - **Do not publish broken updater metadata.** Before making a desktop release public/latest, verify `latest-mac.yml` references assets that exist and that - the expected arm64/x64 DMGs and ZIPs are present. + the expected arm64/x64 DMGs and ZIPs are present. When Windows is enabled, + apply the same rule to `latest.yml` and the Windows installer. +- **Do not publish a half-platform release.** The Windows gate and the Windows + assets must agree. If `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1` the draft + must carry Windows assets; if it is not `1` the draft must carry none. Either + mismatch means the workflow did not do what you think it did, so keep the + release draft/private and investigate before publishing. - **Do not discover obvious release blockers after upload.** Preflight iOS App Clip packaging metadata before starting the expensive mobile phase. - **Do not wait forever.** If GitHub notarization or TestFlight processing @@ -55,13 +62,24 @@ This release lane runs on an Apple Silicon Mac (`arm64`), but desktop release artifacts are produced remotely by GitHub Actions. Treat local desktop packaging scripts as diagnostic/recovery tools only. -Desktop updater correctness requires: +Desktop updater correctness requires, on macOS: - `latest-mac.yml` - one arm64 ZIP and one x64 ZIP referenced by that file - one arm64 DMG and one x64 DMG - no universal ZIP in the updater feed +and, when `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1`, additionally on Windows: + +- `latest.yml` +- one `ADE--win-x64.exe` installer referenced by that file +- the matching `ADE--win-x64.exe.blockmap` + +Windows builds fresh on the tag alongside macOS. There is one repository +variable, `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED`; it decides whether the release +carries Windows at all. Read it before verifying assets, because it determines +which of the two asset matrices below is correct. + ## State and Locking Create a state file before mutating release state: @@ -80,7 +98,7 @@ Track: ```json { - "desktop": { "needed": false, "version": null, "tag": null, "lastTag": null }, + "desktop": { "needed": false, "version": null, "tag": null, "lastTag": null, "platforms": null }, "ios": { "needed": false, "marketingVersion": null, "buildNumber": null, "lastTag": null }, "phase": "detect|docs|desktop|ios|verify|done|blocked", "notes": [] @@ -132,12 +150,31 @@ relevant preflights pass. - `.github/workflows/release-core.yml` builds `dist:mac:arm64:signed`. - `.github/workflows/release-core.yml` builds `dist:mac:x64:signed`. + - `.github/workflows/release-core.yml` builds `dist:win:signed` in + `build-win-release`. - The publish job merges per-arch manifests into one `latest-mac.yml`. + - The publish job attaches the Windows installer, its `.blockmap`, and + `latest.yml` when the Windows gate is on. If the workflow has been changed to publish universal updater ZIPs, stop and fix the workflow before releasing. -7. For iOS releases, preflight App Clip packaging metadata before archiving: +7. For desktop releases, resolve the expected platform matrix before tagging. + This decides what the draft must contain in Phase 4: + + ```bash + gh variable get ADE_WINDOWS_PUBLIC_RELEASE_ENABLED --repo arul28/ADE 2>/dev/null || echo "unset" + ``` + + - `1` means the release must carry macOS **and** Windows assets. Record + `platforms=mac,win`. + - Anything else, including unset, means macOS only. Record `platforms=mac`. + + Windows signing is fail-closed: if the gate is `1` and the signing secrets + are missing, the `verify` job stops the run in about a minute. Do not + "fix" that by clearing the gate mid-release; fix the secrets or stop. + +8. For iOS releases, preflight App Clip packaging metadata before archiving: ```bash xcodebuild -showBuildSettings \ @@ -333,9 +370,16 @@ Expected shape: - runtime/resource jobs run first - `arm64 mac release` and `x64 mac release` build/sign/notarize independently +- `build-win-release` builds/signs/validates Windows independently, in parallel + with the mac jobs, when `platforms` includes `win`. With the gate off it is + skipped, and a skipped Windows job does not block the mac release. - `publish-release` merges the per-arch updater manifests and creates the draft - `update-brew-tap` runs after publication +If `platforms=mac,win` and `build-win-release` did not run, stop. The gate and +the run disagree, and publishing would ship a macOS-only release under a +version that is supposed to carry Windows. + ### Retry policy Do not start duplicate full release workflows. @@ -376,13 +420,51 @@ gh release download "v" --repo arul28/ADE \ cat ".ade/tmp/release-v-verify/latest-mac.yml" ``` -Required assets: +When `platforms` includes `win`, also pull the Windows updater feed: + +```bash +gh release download "v" --repo arul28/ADE \ + --pattern latest.yml \ + --dir ".ade/tmp/release-v-verify" \ + --clobber +cat ".ade/tmp/release-v-verify/latest.yml" +``` + +Required assets, always: - `ADE--arm64.dmg` - `ADE--arm64.zip` - `ADE--x64.dmg` - `ADE--x64.zip` - `latest-mac.yml` +- `install.sh` +- `SHA256SUMS` +- `ade-darwin-arm64`, `ade-darwin-x64`, `ade-linux-arm64`, `ade-linux-x64`, and + the matching `.native.tar.gz` for each + +Required additionally when `platforms` includes `win`: + +- `ADE--win-x64.exe` +- `ADE--win-x64.exe.blockmap` +- `latest.yml` +- `install.ps1` +- `ade-win32-x64.exe` +- `ade-win32-x64.native.tar.gz` + +Gate/asset agreement is a hard check, in both directions: + +```bash +WINDOWS_GATE="$(gh variable get ADE_WINDOWS_PUBLIC_RELEASE_ENABLED --repo arul28/ADE 2>/dev/null || echo unset)" +WINDOWS_ASSETS="$(gh release view "v" --repo arul28/ADE --json assets \ + --jq '[.assets[].name | select(test("win-x64|win32-x64|^latest\\.yml$|^install\\.ps1$"))] | length')" +echo "gate=$WINDOWS_GATE windows_assets=$WINDOWS_ASSETS" +``` + +- `gate=1` and `windows_assets=0` means the Windows build silently did not + contribute. Stop; keep the release draft/private. +- `gate` not `1` and `windows_assets` greater than `0` means Windows assets + reached a release that was not supposed to carry them. Stop; keep the release + draft/private. Also verify: @@ -391,6 +473,12 @@ Also verify: - no updater ZIP is suspiciously huge; a ZIP over about 900 MB needs human review because Squirrel.Mac can crash while handling oversized updater ZIPs. - every `latest-mac.yml` referenced ZIP exists in the release assets. +- when Windows is in scope, `latest.yml` references the uploaded + `ADE--win-x64.exe`, and that installer and its `.blockmap` both + exist in the release assets. +- `SHA256SUMS` lists every published standalone runtime asset, including the + `ade-win32-x64` entries when Windows is in scope, and lists nothing that is + not published. ### Publish public/latest @@ -597,6 +685,15 @@ Desktop: until fixed. - If `latest-mac.yml` references a universal ZIP, keep the release draft/private and fix the GitHub workflow. Do not publish the release. +- If `latest.yml` is missing, or references an installer that is not in the + release assets, keep the release draft/private. Windows in-app update reads + that file; a broken feed strands installed Windows users. +- If the Windows build fails, the draft is not created at all while the gate is + on, by design. Fix the failure and rerun; do not clear + `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` to force a macOS-only draft under a + version that was announced as carrying Windows. +- If the Windows gate and the published Windows assets disagree in either + direction, keep the release draft/private and reconcile before publishing. iOS: @@ -613,8 +710,12 @@ iOS: Report: - desktop scope decision and tag +- the resolved desktop platform matrix (`mac` or `mac,win`) and the + `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` value it came from - GitHub Release URL and asset count - whether `latest-mac.yml` references only present assets +- when Windows is in scope, whether `latest.yml` references only present assets + and whether the gate and the published Windows assets agreed - iOS marketing/build number - TestFlight build ID - group membership verification diff --git a/AGENTS.md b/AGENTS.md index 433384c9f..141983637 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ iOS signing gotchas (don't repeat these): Desktop release: -- Tag a commit on `main` with `vX.Y.Z` and push the tag. `.github/workflows/release.yml` triggers, runs the `release-core.yml` job, and publishes a draft GitHub Release with `.dmg`, `.zip`, blockmap, and `latest-mac.yml` assets. The workflow requires the tagged commit to be an ancestor of `origin/main`. +- Tag a commit on `main` with `vX.Y.Z` and push the tag. `.github/workflows/release.yml` triggers, runs the `release-core.yml` job, and publishes a draft GitHub Release. The workflow requires the tagged commit to be an ancestor of `origin/main`. Assets are the macOS `.dmg` and `.zip` plus `latest-mac.yml`, and the standalone runtime set (`install.sh`, `SHA256SUMS`, and the `ade-darwin-*`/`ade-linux-*` binaries with their `.native.tar.gz` archives). When the repository variable `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1`, Windows builds fresh on the same tag and adds `ADE--win-x64.exe`, its `.blockmap`, `latest.yml`, `install.ps1`, and `ade-win32-x64.exe` with its `.native.tar.gz`. That variable is the only Windows switch; with it off the Windows jobs skip cleanly and do not block the macOS release. - Draft releases stay unpublished until you flip them (`gh release edit vX.Y.Z --draft=false` or the UI). Don't publish silently. - Main is protected by a ruleset: admin bypass is required for direct pushes, and the "strict required status checks" rule makes GitHub's "Merge pull request" button reject merges that use a non-linear history (even when the branch already contains `main`). `gh pr merge --admin` hits the same block; merging locally and pushing (admin bypass) is the fallback. From 786cb73dc6273fe7ba1d65f6fb70470077007bc2 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:49:59 -0400 Subject: [PATCH 12/27] docs(release): describe the in-run Windows release model Rewrite the release-pipeline bullets in ARCHITECTURE for the model that landed: Windows builds fresh on the tag mirroring build-mac-release, one repository variable gates it, promotion of a previously approved proof run and its four binding variables are gone, and the accepted tradeoff is stated plainly. Record that proof collection survives as an opt-in mode that reads no repository variable, that the evidence bundle's 90-day retention now bounds review time rather than tagging, and that every published asset comes from the run that built it. Based-on: nsxdavid/ADE#999 (cherry picked from commit c943ec73bdad8f439d8431b6719e4848dab4e666) --- docs/ARCHITECTURE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 737d1f7ec..ab67ca2d2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1513,11 +1513,11 @@ Windows: - Windows-only wrappers for the bundled `ade` CLI ship in `apps/desktop/scripts/`: `ade-cli-windows-wrapper.cmd` (channel-aware launcher), `ade-cli-install-path.cmd` (idempotent PATH install helper), and `windows-install-setup.ps1` (post-install CLI/service setup). The platform-agnostic `.sh` wrapper covers macOS/Linux. - The Windows installer is an assisted, per-user, non-elevating NSIS install. It bundles the prebuilt `cr-sqlite` native binary from `apps/desktop/vendor/crsqlite/win32-x64/`, a Windows node-pty ConPTY worker, and all Darwin/Linux remote-runtime sidecars. `validate-win-artifacts.mjs` asserts each one is unpacked and executes a real CRR change through the installed DLL. The custom install step repairs the channel-specific CLI shim, user PATH, and brain startup registration; uninstall removes only registry/PATH/protocol/startup state owned by that exact installation. - Electron-builder generates `resources/app-update.yml` from the GitHub publish configuration. The source default remains the upstream `arul28/ADE`; CI sets `ADE_RELEASE_REPOSITORY=${{ github.repository }}` so fork package validation proves the installed updater authority matches the repository that built it without changing upstream release behavior. -- `release-core.yml` runs the signed Windows job only when repository variable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`. If enabled, missing signing credentials or pinned publisher identity fail both the desktop and standalone-runtime signing paths and block publication; if disabled, the skipped Windows job does not block the macOS release. The installer, updater metadata, `install.ps1`, signed `ade-win32-x64.exe`, and its native archive are added to the draft only when both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`. Keep both publication gates off until clean-host install checks and the mandatory two-unpublished-version N-to-N+1 signed update proof pass; that proof includes timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. -- `release-core.yml` keeps Windows proof building and publication promotion as separate jobs. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, the non-publishing proof job requires signing credentials and a pinned publisher identity for the desktop and standalone runtime. A normal publishing workflow skips Windows cleanly while `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is off. Once the public and installed-update-proof gates are on, promotion retrieves the immutable approved proof-run artifact instead of signing or rebuilding a second set. If signed builds are disabled, the skipped Windows jobs do not block the macOS release. -- `prepare-release.yml` is the platform-neutral non-publishing dry run and stays invokable in every Windows flag state. Its `windows_proof` dispatch input opts into signed Windows proof mode, which additionally requires `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` with `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` other than `1` so proof is always collected while publication is disabled. Proof mode emits `windows-proof-manifest.json` for the exact 40-character source SHA and hashes the installer, blockmap, updater manifest, standalone executable, native archive, `install.ps1`, `SHA256SUMS`, full-system scenario inventory, and #999 provenance index. It verifies that the checksum manifest binds the standalone files. Public Windows publication additionally requires the protected exact SHA, proof run id, original build-manifest digest, and installed-update approval. Promotion validates those values plus the release tag and file hashes before draft assembly; the website download flag remains a separate disabled-by-default gate. See `docs/development/windows-release-proof.md`. -- The approved proof artifact is uploaded with an explicit 90-day `retention-days`, and promotion checks that it is still retained before downloading it so an aged-out proof fails with a named recovery path instead of a raw download error. -- Draft assembly never mixes runs for one asset name. The published cross-platform standalone set (`install.sh`, `ade-darwin-*`, `ade-linux-*`) always comes from the current run, because darwin runtime binaries are notarized per run and must stay byte-identical to the copies inside the DMGs published beside them. Only the genuinely Windows-specific members of the approved proof are published: the installer, its blockmap, `latest.yml`, `install.ps1`, and the signed `ade-win32-x64` executable and native archive. The published `SHA256SUMS` is regenerated over that merged set, so the digests the runtime installers verify always describe the bytes actually uploaded; the proof bundle's own `SHA256SUMS` stays proof-internal. +- Windows builds fresh on the release tag, the same shape as macOS. `build-win-release` mirrors `build-mac-release`: it builds, signs and validates on a `windows-latest` runner in-run, and `publish-release` attaches its output. `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` is the only Windows repository variable and the only maintainer-facing switch; with it off the Windows jobs skip cleanly and do not block the macOS release, and with it on a failed or skipped Windows build blocks the draft exactly as a failed macOS build does. Signing is fail-closed at every layer: the job-level secret requirement, `--require-signing` in `run-electron-builder.mjs`, and `--require-signed` Authenticode verification in `validate-win-artifacts.mjs` covering valid status, trusted RFC3161 timestamp, and pinned subject/thumbprint. `verify` re-checks that the signing secrets exist before any runner spins up, so a misconfigured gate fails in about a minute. +- Publication no longer depends on promoting a previously approved proof run. That model, and the four repository variables that bound it, are gone. The accepted tradeoff is that a release may publish Windows bytes that never went through a manual clean-host proof sweep; the machine-checkable gates above all still apply, and the priority is that every desktop and CLI release also publishes Windows automatically. +- `prepare-release.yml` is the platform-neutral non-publishing dry run and stays invokable in every flag state. Its `windows_proof` dispatch input is threaded through `release-core.yml` and turns on deliberate clean-host evidence collection: it builds and signs Windows even while the publication gate is off, and gates the proof staging, manifest generation, manifest validation, and bundle upload. Proof mode reads no repository variable, so evidence can be collected before Windows is enabled and again as a regression check afterwards. It emits `windows-proof-manifest.json` for the exact 40-character source SHA and hashes the installer, blockmap, updater manifest, standalone executable, native archive, `install.ps1`, `SHA256SUMS`, full-system scenario inventory, and #999 provenance index, and verifies that the checksum manifest binds the standalone files. The website download flag remains a separate disabled-by-default gate. See `docs/development/windows-release-proof.md`. +- The `ade-win-proof-` evidence bundle is uploaded with an explicit 90-day `retention-days` so a lowered repository or organization default cannot expire it mid-review. It bounds how long the evidence stays downloadable, not when a release may be tagged. +- Every published asset comes from the run that built it. The cross-platform standalone set (`install.sh`, `ade-darwin-*`, `ade-linux-*`) and the Windows standalone set (`install.ps1`, `ade-win32-x64.exe` and its native archive) all come from that run's `build-runtime-binaries` jobs, which is what keeps them byte-identical to the copies bundled inside the desktop installers published beside them. The published `SHA256SUMS` is regenerated over the merged standalone set, so the digests the runtime installers verify always describe the bytes actually uploaded. A cross-platform runtime allowlist rejects any extra or missing `ade-*` sidecar in every flag state, the Windows standalone bytes are cross-checked against the digests computed on the signing runner, and `latest.yml` must reference the installer published beside it. - Ongoing Windows integration lane (rebase with `main`, smoke tests, backlog): `docs/development/windows-port-lane.md`. Post-packaging hardening (`apps/desktop/scripts/`): From 8624167b267a589930bcd92f98c71ca882c92854 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:50:47 -0400 Subject: [PATCH 13/27] test(release): pin the Windows standalone checksum normalization The Windows standalone checksum manifest is written by Git Bash on windows-latest, where sha256sum reads in binary mode and marks each name with a leading asterisk. Publishing runs on Linux, where the two-space form is required. Assert the normalization is present so a future edit cannot quietly drop it and turn the cross-runner digest check into a hard release failure. Based-on: nsxdavid/ADE#999 (cherry picked from commit adcc755de4aa0d5e49836a2a33058c9e31cab4de) --- apps/desktop/scripts/windows-release-contract.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index 3cb9b8217..06e2e2168 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -281,7 +281,11 @@ test("standalone Windows release assets remain behind the publication gate", () assert.match(publish, /test -s release-assets\/runtime\/install\.ps1/); assert.match(publish, /test -s release-assets\/runtime\/ade-win32-x64\.exe/); assert.match(publish, /test -s release-assets\/runtime\/ade-win32-x64\.native\.tar\.gz/); - assert.match(publish, /\(cd release-assets\/runtime && sha256sum -c SHA256SUMS\)/); + // The Windows standalone manifest is written by Git Bash on windows-latest, + // where sha256sum marks binary reads with a leading '*'. It is normalized + // before it is parsed or verified. + assert.match(publish, /sed 's\/\^\\\(\[0-9a-f\]\\\{64\\\}\\\) \[ \*\]\/\\1 \/'/); + assert.match(publish, /\(cd release-assets\/runtime && sha256sum -c "\$windows_sums"\)/); // Windows standalone assets are named only inside the publication-gated // branch, and every published asset is sourced from this run. assert.match(publish, /if \[ "\$PUBLISH_WINDOWS" = "1" \]; then[\s\S]*release-assets\/runtime\/install\.ps1[\s\S]*release-assets\/runtime\/ade-win32-x64\.exe/); From 2cc9c34dca1669d2ad038fdb60240d45835f69f6 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:54:16 -0400 Subject: [PATCH 14/27] docs(release): drop deleted Windows gate variables from supporting docs Three docs outside the signing playbook still instructed maintainers to set or reason about repository variables that no longer exist: the proof contract told them to bind the approved proof SHA, run id and build-manifest digest so a release could promote those bytes; the port lane told them to enable a separate signed-build variable and bind an approved proof run before enabling promotion; and the auto-update doc described release generation and public availability as two variables. Point all three at the single ADE_WINDOWS_PUBLIC_RELEASE_ENABLED gate and the windows_proof dispatch input, rename the evidence artifact to ade-win-proof-v, and state in the proof contract that a published installer carries its own signing timestamp and is therefore not the byte-identical artifact the proof indexes. Based-on: nsxdavid/ADE#999 (cherry picked from commit 356d365eeb6ae14326e553815b3be9ad0d6fb5fb) --- docs/development/windows-port-lane.md | 5 ++-- docs/development/windows-release-proof.md | 27 +++++++++---------- .../desktop-auto-update.md | 17 ++++++------ 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index 81903817c..e5b068c81 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -69,7 +69,8 @@ Recent `main` work that is **not** inherently macOS-only but can surface path/sh Windows Graphics Capture/UI Automation is not implemented. - **iOS Simulator / Attention Notch** — hidden on Windows by capability. These remain macOS-only product surfaces. -- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, the proof workflow fails unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match the pinned subject or thumbprint; the installer and packaged app must also share one certificate. Public readiness still requires immutable exact-SHA proof for the Windows runtime, native archive, installer, and checksum manifest. Disabled Windows publication does not block the macOS release path. SmartScreen reputation remains a release-engineering concern, not only app code. +- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. Release builds fail unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match the pinned subject or thumbprint; the installer and packaged app must also share one certificate. Windows builds fresh on the release tag behind the single `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` gate; disabled Windows publication does not block the macOS release path. SmartScreen reputation remains a release-engineering concern, not only app code. +- **Windows as an SSH-bootstrap target** — local standalone install and `ade brain update` are implemented, and desktop remote bootstrap now detects and uploads native Windows targets alongside macOS/Linux. A Windows client can bootstrap those targets and reports actionable OpenSSH Client prerequisite diagnostics. HKCU startup-entry replacement is retry-safe but not transactional; Scheduled Tasks are legacy cleanup only. - **Docs in `AGENTS.md`** still emphasize macOS Codex/Computer Use; Windows developers should use this file + `docs/ARCHITECTURE.md` for WSL/VM dev notes if applicable. ## External proof gates before public availability @@ -99,7 +100,7 @@ verification. Complete these before enabling the public website/release flags: 6. **Signed installer** — verify the installer and installed app use the approved publisher, then install, relaunch, log off/on, uninstall, and reinstall. Provision signing credentials and monitor SmartScreen reputation. Before publication, use two unpublished signed versions to prove N to N+1 update, timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. -7. **Proof, promotion, and public gates** — enable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` to produce the non-publishing signed proof build while `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0` and `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` is unset or `0`. Complete the exact-SHA inventory, including the private signed N to N+1 update and standalone runtime, then bind the approved proof run, manifest digest, and installed-update approval before enabling promotion. Inspect the assembled unpublished draft before publication. Enable the website only after that same release is public and verified. +7. **Proof and public gates** — run `prepare-release.yml` with `windows_proof=true` to produce a non-publishing signed build and its evidence bundle while `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is unset or `0` and `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` is unset or `0`. Complete the exact-SHA inventory, including the private signed N to N+1 update and standalone runtime. That sweep is the recommended pre-enablement validation, not a pipeline gate: once `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1`, every tag builds and publishes Windows on its own. Inspect the assembled unpublished draft before publication. Enable the website only after that same release is public and verified. The machine-readable scenario inventory and evidence rules are in [Windows release proof](./windows-release-proof.md). The complete maintainer diff --git a/docs/development/windows-release-proof.md b/docs/development/windows-release-proof.md index 7794f5a13..eca79af55 100644 --- a/docs/development/windows-release-proof.md +++ b/docs/development/windows-release-proof.md @@ -15,11 +15,12 @@ the existing approved download may disrupt users. ## Inputs and outputs -Run `.github/workflows/prepare-release.yml` with a version and the lowercase, -40-character commit SHA intended for release. The workflow requires -`ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, refuses to run when public Windows -publication is enabled, checks out the exact SHA, verifies `ci-pass` for it, -builds without publication, and uploads `ade-win-release-v` containing: +Run `.github/workflows/prepare-release.yml` with a version, the lowercase, +40-character commit SHA intended for release, and `windows_proof=true`. Proof +mode requires only the Windows signing secrets, so it can run before Windows +publication is enabled and again afterwards as a regression check. The workflow +checks out the exact SHA, verifies `ci-pass` for it, builds without publication, +and uploads `ade-win-proof-v` containing: - `ADE--win-x64.exe` - `ADE--win-x64.exe.blockmap` @@ -226,15 +227,13 @@ person's name or account identifier. Run `--phase publication-readiness` with both roots. This phase still requires `publicReleaseEnabled` and `websiteReleaseReady` to be false. -After that validation succeeds, set the protected repository variables -`ADE_WINDOWS_APPROVED_PROOF_SHA`, `ADE_WINDOWS_APPROVED_PROOF_RUN_ID`, and -`ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256` to the exact approved source SHA, -the original non-publishing workflow run id, and the SHA-256 of the original -`proof_pending` build manifest. The public release workflow downloads that -immutable Actions artifact, verifies the manifest digest, run id, tag, exact -source SHA, and all artifact hashes, and promotes those same installer bytes. -It does not rebuild Windows with a new signing timestamp. Enabling publication -and the website remains a separate explicit maintainer action described in the +After that validation succeeds, the proof is complete. It is the record that +this commit was exercised on clean Windows hosts; it is not a release gate. +Releases build Windows fresh on the tag, so a published installer carries its +own signing timestamp and is not the byte-identical artifact this proof +indexes. Re-run proof mode against a later commit whenever you want a fresh +clean-host record. Enabling publication and the website remains a separate +explicit maintainer action described in the signed-release playbook. After the tag workflow creates the unpublished draft, collect the bounded diff --git a/docs/features/onboarding-and-settings/desktop-auto-update.md b/docs/features/onboarding-and-settings/desktop-auto-update.md index eb5092959..a47fbb4fb 100644 --- a/docs/features/onboarding-and-settings/desktop-auto-update.md +++ b/docs/features/onboarding-and-settings/desktop-auto-update.md @@ -48,14 +48,15 @@ Windows x64 uses electron-builder's per-user NSIS target and to match `ADE_RELEASE_REPOSITORY`, preventing a fork build from silently checking a different repository. -Release generation and public availability are separate gates. -`ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` enables a fail-closed Authenticode build; -the installer and packaged `ADE.exe` must share the pinned publisher identity -and carry a trusted RFC3161 timestamp. `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` -adds the installer, blockmap, and `latest.yml` to the draft release. Keep the -public gate disabled until the signed installer passes the clean standard-user -Windows checks. Validate version-to-version automatic updating after two signed -Windows releases exist. +`ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` is the single gate. It builds Windows +fresh on the release tag and adds the installer, blockmap, and `latest.yml` to +the draft release. The build is fail-closed on Authenticode: the installer and +packaged `ADE.exe` must share the pinned publisher identity and carry a trusted +RFC3161 timestamp, or the release fails. Keep the gate disabled until the signed +installer has passed the clean standard-user Windows checks, which the +`windows_proof` dispatch input on `prepare-release.yml` produces without +publishing anything. Validate version-to-version automatic updating after two +signed Windows releases exist. ## Required-space estimate From 12071e5db9e9d84bed9cb61b9773d4d4c84cd295 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 01:56:23 -0400 Subject: [PATCH 15/27] docs(release): rewrite the signing playbook for the in-run Windows build The playbook was built entirely around approved-proof promotion. It told maintainers to set five repository variables, bind an approved proof SHA, run id and build-manifest digest, and re-prove every later release by first disabling the public gate. None of that exists any more. Reduce enablement to one variable and one command, and reframe the clean-host sweep as strongly recommended pre-enablement validation and an ongoing regression check rather than a gate. State the accepted tradeoff plainly and once: the pipeline enforces nothing about the sweep, so a release can publish Windows bytes no human installed on a clean machine. Keep the signing language exactly as strong as the code: signing is mandatory and machine-enforced at every layer, and there is no unsigned Windows publication path. Rename the downloaded evidence bundle to ade-win-proof-v, note that ade-win-release-v now holds only the installer set, restate the retention window as bounding evidence review rather than tagging, and replace the promotion failure modes with the ones that now matter: the Windows build failing while the gate is on, and the gate disagreeing with the draft's Windows assets in either direction. Also clarify in the proof contract that releaseGates describes the proof run rather than repository state, so a regression sweep collected after enablement still records publicReleaseEnabled false truthfully, and that signedBuildEnabled is a historical field name. Based-on: nsxdavid/ADE#999 (cherry picked from commit 32fefb97466156278866fb3bcc98be42ccb7d0ea) --- docs/development/windows-release-proof.md | 2 +- docs/playbooks/windows-signed-release.md | 276 ++++++++++------------ 2 files changed, 123 insertions(+), 155 deletions(-) diff --git a/docs/development/windows-release-proof.md b/docs/development/windows-release-proof.md index eca79af55..d94eef4f6 100644 --- a/docs/development/windows-release-proof.md +++ b/docs/development/windows-release-proof.md @@ -93,7 +93,7 @@ personal identifiers; visual and semantic redaction still requires review. | `buildValidation` | `signedBuild: true`; canonical artifact validator; Authenticode, RFC3161 timestamp, signer-consistency, and publisher-pin gates all `passed`. | | `artifacts` | Exactly one each of `installer`, `blockmap`, `update-manifest`, `standalone-runtime`, `standalone-native-archive`, `standalone-installer`, and `runtime-checksums`, with fixed top-level filenames, lowercase SHA-256, and positive byte size. The checksum manifest must bind the other three standalone Windows entries. | | `indexes` | Fixed paths and SHA-256 values for `windows-full-system-scenarios.json` and `windows-source-provenance.json`. | -| `releaseGates` | Signed build enabled; non-publishing workflow true; GitHub Release creation, public Windows release, and website release readiness all false. `websiteReleaseReady` records approval state for this release, not whether a previously approved download is currently visible. | +| `releaseGates` | Properties of **this proof run**, not of the repository at validation time: the build was signed, the workflow was non-publishing, and it created no GitHub Release, made nothing publicly available, and made nothing visible on the website. `signedBuildEnabled` is a historical field name; signing is now unconditional for any Windows build. Because these describe the run, a regression sweep collected after `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` still records `publicReleaseEnabled: false` truthfully. `websiteReleaseReady` records approval state for this release, not whether a previously approved download is currently visible. | | `approval` | `proof_pending`, `proof_complete`, or `approved`; publication readiness binds role-based approval to `approvedTargetSha`. | | `scenarioResults` | Exactly one result for every scenario id: status, redacted host aliases, evidence ids, and optional machine-safe blocker code. | | `evidence` | Indexed, hashed, redacted evidence records described below. | diff --git a/docs/playbooks/windows-signed-release.md b/docs/playbooks/windows-signed-release.md index f9fa292ec..90f78c1a5 100644 --- a/docs/playbooks/windows-signed-release.md +++ b/docs/playbooks/windows-signed-release.md @@ -2,13 +2,13 @@ Use this guide after the Windows release changes have been merged into `main`. -ADE already publishes macOS and standalone runtime files through GitHub Releases. The Windows release uses that same pipeline and includes the standalone Windows payload. +ADE already publishes macOS and standalone runtime files through GitHub Releases. Windows uses that same pipeline and includes the standalone Windows payload. Windows builds fresh on the release tag, the same way macOS does. After setup, a normal release is: 1. A maintainer tags an approved commit. -2. GitHub Actions builds and checks the other platforms, then retrieves and - verifies the immutable Windows artifact approved by the exact-SHA proof. +2. GitHub Actions builds, signs, and validates every platform from that tag, + including Windows. 3. GitHub Actions creates one unpublished GitHub Release. 4. A maintainer checks it and makes it public. @@ -21,16 +21,18 @@ Each Windows release adds one file for people to download: `ADE--win-x6 Complete these actions in order: 1. Configure the Windows signing credentials in GitHub Actions. -2. Enable signed test builds while keeping Windows publication off. -3. Build a signed test version in GitHub Actions. -4. Complete the exact-SHA proof inventory on clean Windows 10 and Windows 11 computers, including a signed N to N+1 private update. -5. Validate and approve the redacted proof bundle while publication and the website remain disabled. -6. Bind the approved proof SHA, run id, and build-manifest digest, attest the - installed-update proof, and enable Windows in the production release - workflow. -7. Tag the approved commit. GitHub Actions builds the other platforms, promotes - the exact approved Windows artifact, and creates one unpublished release. -8. Check the release, make it public, and only then enable the Windows website link. +2. Build a signed Windows version in GitHub Actions with the non-publishing + proof run. +3. Complete the exact-SHA proof inventory on clean Windows 10 and Windows 11 + computers, including a signed N to N+1 private update. +4. Validate and approve the redacted proof bundle while publication and the + website remain disabled. +5. Set `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1`. That single switch makes tagged + releases publish Windows. +6. Tag the approved commit. GitHub Actions builds every platform from that tag + and creates one unpublished release. +7. Check the release, make it public, and only then enable the Windows website + link. The sections below provide the commands and pass conditions. @@ -38,6 +40,16 @@ The sections below provide the commands and pass conditions. Perform these actions once before the first public Windows release. +Step 1 is required. Without the signing secrets no Windows build runs at all. + +Steps 2 through 4 are the clean-host proof sweep. They are strongly recommended +before you enable Windows, and worth repeating after enablement as an ongoing +regression check. The pipeline does not enforce them. Nothing in +`release-core.yml` checks that a sweep happened, so a release can publish +Windows bytes that no human ever installed on a clean machine. That is the +accepted tradeoff for having every desktop and CLI release publish Windows +automatically. + ### 1. Configure signing The current workflow supports a password-protected PFX/P12 signing certificate. If you choose a signing service that does not provide one, such as Microsoft Artifact Signing, adapt the workflow to that service before continuing. @@ -60,24 +72,18 @@ gh secret set WINDOWS_SIGNING_EXPECTED_THUMBPRINT --repo arul28/ADE Each command asks for the secret without printing it in the command. -Enable signed test builds, but keep Windows out of public releases: - -```bash -gh variable set ADE_WINDOWS_SIGNED_BUILD_ENABLED \ - --repo arul28/ADE --body 1 -gh variable set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \ - --repo arul28/ADE --body 0 -``` +Keep `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` unset or `0` until Step 5. -### 2. Run the signed build in GitHub Actions +### 2. Run the signed proof build in GitHub Actions `prepare-release.yml` is the platform-neutral non-publishing dry run. Pass -`windows_proof=true` to also assert the signed Windows proof preconditions: -`ADE_WINDOWS_SIGNED_BUILD_ENABLED` must be `1` and -`ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` must not be `1`, so proof is always -collected while publication is disabled. Without that input the same workflow -stays the ordinary macOS and standalone runtime validation run and can be -dispatched in any Windows flag state. +`windows_proof=true` to build and sign Windows and emit the exact-SHA proof +bundle. Proof mode reads no repository variable; it only requires the signing +secrets. Collect proof before Windows is enabled, and again after it is enabled +as a regression check. Without that input the same workflow stays the ordinary +dry run, which still builds and validates Windows whenever +`ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1` and skips Windows otherwise. Neither +mode creates a GitHub Release. Use the version and commit intended for the first Windows release: @@ -105,22 +111,31 @@ The run passes only when: - The Windows installer is signed by the approved publisher. - The installer and installed `ADE.exe` use the same certificate. - The Windows installer, blockmap, and update information match. +- The installed-product lifecycle smoke test passes on the runner. - Required bundled tools and runtime files are present. - The macOS and standalone runtime jobs still pass. - No GitHub Release is created. - A machine-readable proof manifest is generated for the exact checked-out SHA. -Download the `ade-win-release-v` artifact from the successful run. +Download the `ade-win-proof-v` artifact from the successful run. The +separate `ade-win-release-v` artifact from the same run holds only the +installer, its `.blockmap`, and `latest.yml`; that is the artifact a publishing +run attaches to the draft. -> **Retention window.** That artifact is uploaded with `retention-days: 90`. A -> public release promotes those exact bytes, so the tag in Publish step 2 must -> happen within 90 days of this proof run. After that the artifact expires, -> promotion stops with an explicit retention error, and you must repeat steps 2 -> through 4 for the approved commit and rebind -> `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` and -> `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256`. +> **Retention window.** The proof bundle is uploaded with `retention-days: 90`. +> That window bounds how long the clean-host evidence stays downloadable for +> human review. It does not bound when you may tag a release. A release never +> reuses bytes from this run, so an expired proof bundle cannot block a release. +> It only means the evidence must be collected again if you still need it. -Its manifest-indexed files must include: +Record the run id. The final proof phase in Publish step 3 checks the manifest +against it: + +```powershell +$PROOF_RUN_ID = "" +``` + +The bundle's manifest-indexed files must include: - `ADE--win-x64.exe` - `ADE--win-x64.exe.blockmap` @@ -131,15 +146,6 @@ Its manifest-indexed files must include: - `SHA256SUMS` - `windows-proof-manifest.json` -Before editing the manifest, record the immutable build identity: - -```powershell -$PROOF_RUN_ID = "" -$BUILD_MANIFEST_SHA256 = (Get-FileHash ` - -LiteralPath \windows-proof-manifest.json ` - -Algorithm SHA256).Hash.ToLowerInvariant() -``` - Create the proof-bundle layout from the proof contract: leave the manifest at the bundle root and place all seven manifest-indexed files under its `artifacts/` directory before running the validator. Keep any additional @@ -193,70 +199,38 @@ roots. Leave `draft-assets-website-explicit` pending until the unpublished draft exists in Publish step 3. An authorized Windows release maintainer then changes the role-only approval fields as described in the proof contract and runs `--phase publication-readiness`. This validation still requires both public -release and website flags to be false. +release and website flags to be false, so run it before Step 5. -Set the protected proof binding to the manifest's exact SHA, the original -non-publishing run, and the original build-manifest digest: - -```bash -gh variable set ADE_WINDOWS_APPROVED_PROOF_SHA \ - --repo arul28/ADE --body "$RELEASE_SHA" -gh variable set ADE_WINDOWS_APPROVED_PROOF_RUN_ID \ - --repo arul28/ADE --body "$PROOF_RUN_ID" -gh variable set ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256 \ - --repo arul28/ADE --body "$BUILD_MANIFEST_SHA256" -``` - -The public workflow fails closed if any binding is absent or malformed, if the -SHA or release tag differs, or if the retrieved immutable artifact's manifest -or release-file hashes differ. +The supporting evidence lives in the redacted proof bundle assembled in Step 2: +`windows-proof-manifest.json` at the bundle root, the manifest-indexed release +files under `artifacts/`, and the redacted GUI, log, DB, process, IPC, and +network evidence under `evidence/`. The contract for both is +[Windows release proof](../development/windows-release-proof.md). ### 5. Enable Windows releases -After the recorded test results pass, set both publication gates. They are -separate settings and `release-core.yml` requires both; setting only the public -flag makes the tagged release stop in its `verify` job with -`ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires approved two-version -installed-update proof.` +After the recorded test results pass, set the single publication gate: ```bash -gh variable set ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED \ - --repo arul28/ADE --body 1 gh variable set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \ --repo arul28/ADE --body 1 ``` -`ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1` is a maintainer attestation, not -a machine check. It states that an authorized Windows release maintainer has -seen and accepted the two-version installed-update proof for this exact commit: -two signed, non-public builds N and N+1 installed on clean Windows 10 x64 and -Windows 11 x64 hosts, with the private N to N+1 updater path completed -end-to-end, including timestamp and signature validation, tamper rejection, -desktop relaunch, brain service recovery, and user data preservation. - -Set it only when all of the following are true: - -- Every `pre-tag` result in the proof manifest is `pass` and the manifest state - is `proof_complete` (Step 4). -- The `publication-readiness` validator passed while both the public release and - website flags were still disabled (Step 4). -- The updater scenarios in the - [full-system scenario inventory](../development/windows-full-system-scenarios.json) - that cover the signed N to N+1 private update are recorded as `pass`. -- `ADE_WINDOWS_APPROVED_PROOF_SHA` names the same commit that produced that - proof. - -The supporting evidence lives in the redacted proof bundle assembled in Step 2 -and validated in Step 4: `windows-proof-manifest.json` at the bundle root, the -manifest-indexed release files under `artifacts/`, and the redacted GUI, log, -DB, process, IPC, and network evidence under `evidence/`. The contract for both -is [Windows release proof](../development/windows-release-proof.md). Clear this -variable back to `0` whenever the approved proof no longer describes the commit -being released. - -These settings allow the existing release workflow to add validated Windows files to its combined draft. They do not publish a release by themselves. -Keep `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` unset or `0`; website readiness is not -publication approval. +`ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` makes every tagged release build, sign, +validate, and publish Windows alongside macOS and the standalone runtime. It is +the only Windows repository variable. Setting it back to `0` removes Windows +from later releases and leaves the other platforms unchanged. + +The gate does not publish a release by itself; the release workflow still stops +at an unpublished draft. Keep `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` unset or `0`; +website readiness is not publication approval. + +Signing stays mandatory and machine-enforced whenever Windows builds. The +`build-win-release` job requires the signing secrets before it packages +anything, `run-electron-builder.mjs` runs with `--require-signing`, and +`validate-win-artifacts.mjs` runs `--require-signed` Authenticode verification: +valid signature status, a trusted RFC3161 timestamp, and the pinned subject or +thumbprint. There is no unsigned Windows publication path. ### Required GitHub Actions settings @@ -265,12 +239,7 @@ name below is required unless marked optional. | Repository variable | Required when | Meaning | | --- | --- | --- | -| `ADE_WINDOWS_SIGNED_BUILD_ENABLED` | Any signed Windows build or proof | `1` runs the signed Windows proof job and signs the standalone `ade-win32-x64.exe`. Set in Step 1. | -| `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` | Publishing Windows files | `1` lets the tagged release promote and attach the Windows files. Set in Step 5. | -| `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED` | Publishing Windows files | `1` attests the accepted two-version installed-update proof. Set in Step 5. | -| `ADE_WINDOWS_APPROVED_PROOF_SHA` | Publishing Windows files | The approved 40-character commit SHA. Must equal the commit being released. Set in Step 4. | -| `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` | Publishing Windows files | The non-publishing proof run whose artifact is promoted. Its artifact expires 90 days after that run. Set in Step 4. | -| `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256` | Publishing Windows files | SHA-256 of the original `windows-proof-manifest.json`. Set in Step 4. | +| `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` | Publishing Windows files | `1` makes the tagged release build, sign, validate, and attach the Windows files. Set in Step 5. | | Secret | Required when | Meaning | | --- | --- | --- | @@ -287,6 +256,10 @@ name below is required unless marked optional. | `ADE_POSTHOG_PROJECT_TOKEN` | Optional | Analytics token baked into packaged builds. | | `ADE_POSTHOG_HOST` | Optional | Analytics host baked into packaged builds. | +`prepare-release.yml` reads the four `WINDOWS_*` signing secrets directly when +`windows_proof` is set, and passes everything else through with +`secrets: inherit`. + `VITE_ADE_WINDOWS_DOWNLOAD_ENABLED` is not a GitHub setting. It is a Vercel Production variable for the website and is covered in Publish step 5. @@ -301,32 +274,22 @@ Confirm: - The commit is on `main`. - The normal `ci-pass` check succeeded for that exact commit. - The version tag does not already exist. -- The signed-build and public-release settings are `1`. -- `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED` is `1` for this exact commit. It - is required at every Windows gate in `release-core.yml`; without it the tagged - run fails in `verify` with `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires - approved two-version installed-update proof.` See +- `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1`. +- The four Windows signing secrets are still present. With the gate on and a + secret missing, the tagged run stops about a minute in, in `verify`, with + `Windows releases require the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD + secrets.` or `Windows releases require the WINDOWS_SIGNING_EXPECTED_SUBJECT or + WINDOWS_SIGNING_EXPECTED_THUMBPRINT secret to pin the approved publisher.` See [Required GitHub Actions settings](#required-github-actions-settings). -- `ADE_WINDOWS_APPROVED_PROOF_SHA` equals the approved 40-character commit SHA. -- `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` identifies the approved non-publishing run, - and that run finished less than 90 days ago so its artifact has not expired. -- `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256` equals the recorded original - build-manifest digest. -- The proof manifest passed `publication-readiness` with public release and - website gates still disabled. -Confirm the current values before tagging: +Confirm the current value before tagging: ```bash gh variable list --repo arul28/ADE | grep ADE_WINDOWS_ ``` -For later releases, first set `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0` and -`ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=0`, then run the non-publishing -workflow and collect fresh exact-SHA proof for the approved version and commit. -Restore both gates only after that proof is approved. For the first Windows -release, reuse the Step 2 result if its version and commit are unchanged and its -artifact is still within its 90-day retention window. +Run the non-publishing dry run for the exact version and commit first. With the +gate on it builds, signs, and validates Windows too: ```bash VERSION="" @@ -335,10 +298,12 @@ gh workflow run prepare-release.yml \ --repo arul28/ADE \ --ref main \ -f version="$VERSION" \ - -f target_sha="$RELEASE_SHA" \ - -f windows_proof=true + -f target_sha="$RELEASE_SHA" ``` +Add `-f windows_proof=true` when you also want a fresh clean-host proof bundle +for this commit. That is the recommended regression check, not a requirement. + Do not tag until that run succeeds for `RELEASE_SHA`. ### 2. Tag the approved commit @@ -350,10 +315,12 @@ git tag -a "v$VERSION" "$RELEASE_SHA" -m "ADE v$VERSION" git push origin "v$VERSION" ``` -The tag starts `.github/workflows/release.yml`. GitHub Actions rebuilds the -other platforms, promotes the exact approved Windows artifact from the -non-publishing run, verifies its immutable identity and hashes, and creates or -updates an unpublished GitHub Release. +The tag starts `.github/workflows/release.yml`. GitHub Actions builds, signs, +and validates every platform from that tag, including Windows when the gate is +on, and creates or updates an unpublished GitHub Release. + +While the gate is on, a failed or skipped Windows build blocks the draft exactly +as a failed macOS build does. No draft is created. Do not rerun the release workflow after the release is public. The workflow refuses to overwrite assets on a published release. @@ -373,22 +340,20 @@ Require: - The Windows installer, its `.blockmap`, and `latest.yml` are present. - The downloaded Windows installer and installed `ADE.exe` have valid signatures from the approved publisher. - The installed app points to `arul28/ADE` for updates. -- The approved proof SHA equals the release target and the downloaded Windows - files match the proof manifest hashes. - `SHA256SUMS` lists exactly `install.sh`, `install.ps1`, the four `ade-darwin-*` and four `ade-linux-*` files, and `ade-win32-x64.exe` with its native archive, and every listed digest matches the uploaded asset. -The draft mixes two runs on purpose, one asset name per run. `install.sh` and -the `ade-darwin-*` and `ade-linux-*` files come from the tagged run, because the -darwin runtime binaries are notarized per run and must be byte-identical to the -copies inside the DMGs beside them. The installer, its `.blockmap`, `latest.yml`, -`install.ps1`, and the signed `ade-win32-x64` files come from the approved proof -run. `SHA256SUMS` is regenerated over that merged set, so it is not the same file -as the `SHA256SUMS` inside the proof artifact. +Every asset in the draft comes from the tagged run. The published Windows assets +are `ADE--win-x64.exe`, its `.blockmap`, `latest.yml`, `install.ps1`, +`ade-win32-x64.exe`, and `ade-win32-x64.native.tar.gz`. `SHA256SUMS` is +regenerated over that merged standalone set, so it is not the same file as the +`SHA256SUMS` inside any proof bundle. -Add the redacted draft-asset and disabled-website evidence to the proof bundle, -set `draft-assets-website-explicit` to `pass`, then run the final gate: +Only when you are running the clean-host proof sweep for this release: add the +redacted draft-asset and disabled-website evidence to the proof bundle, set +`draft-assets-website-explicit` to `pass`, then run the final gate against the +bundle from that sweep's proof run: ```powershell node apps/desktop/scripts/windows-proof-manifest.mjs validate ` @@ -426,24 +391,27 @@ For the first public Windows release: Later releases use the same website link and do not require another setting change. -The N to N+1 updater path must already have been proven with two signed, -non-public builds before the first public Windows release. Repeat it against -public metadata after the next public signed release as an ongoing regression -check; do not weaken the initial proof requirement merely because public N+1 -does not exist yet. +Prove the N to N+1 updater path with two signed, non-public builds before the +first public Windows release. Repeat it against public metadata after the next +public signed release as an ongoing regression check. ## If something fails - Before publication: keep Windows and the website link disabled, fix the problem, and repeat the failed test. - Existing macOS or standalone runtime job fails: stop the release and fix the shared workflow. +- `build-win-release` fails while the gate is on: no draft is created, because + `publish-release` requires a successful Windows build. Fix the source, choose a + higher version, and tag again. Do not turn the gate off to force a draft out. +- Gate is on but the draft carries no Windows assets: the draft did not come + from a complete run of this workflow. Leave it unpublished, delete it, and + rerun the release for that tag. +- Gate is off but the draft carries Windows assets: the assets are stale or were + added by hand. Leave it unpublished, delete it, and rerun the release for that + tag with the intended gate value. - Problem found in an unpublished release: leave it unpublished, fix the source, choose a higher version, and rebuild. -- Problem found after publication: hide the website link, disable Windows publication, fix and test a higher version, then re-enable Windows before tagging it. -- Promotion stops with a retention or missing-artifact error: the approved proof - run is older than its 90-day artifact retention window, its run was deleted, or - `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` points at the wrong run. Repeat One-time - setup steps 2 through 4 for the approved commit and rebind - `ADE_WINDOWS_APPROVED_PROOF_RUN_ID` and - `ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256`. Never hand-upload a replacement. +- Problem found after publication: hide the website link, set + `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0`, fix and test a higher version, then + set it back to `1` before tagging that version. - Signing certificate changes: update the signing secrets and repeat the signed installer tests. - Release workflow is rerun after publication: the workflow stops instead of replacing public files. Publish a higher version for any correction. From 0df3f1d33c52704088a7eb7d35b7dc7a01a7942a Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 16:43:23 -0400 Subject: [PATCH 16/27] docs(sync): state that Linux is a runtime target, not a sync peer ADE vendors cr-sqlite for darwin-arm64, darwin-x64 and win32-x64 only. The architecture and CRDT docs claimed Linux loads crsqlite.so, which has never been true: package-native-deps warn-and-skips linux, so every release ships a Linux brain with no CRR engine. Rather than close the gap by vendoring a Linux binary, this records the support line as it actually is. A Linux host is a remote runtime target that a macOS or Windows desktop drives over SSH -- a role that does not touch cr-sqlite at all -- and is not a sync peer. That keeps the capability we really have without claiming replication we do not. The packaging note now distinguishes an intentional exclusion from an unvendored target, so a Linux build no longer emits a WARNING that reads as a defect. Based-on: nsxdavid/ADE#999 --- apps/ade-cli/scripts/package-native-deps.mjs | 26 +++++++++++++++---- docs/ARCHITECTURE.md | 2 +- .../sync-and-multi-device/crdt-model.md | 9 ++++--- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/ade-cli/scripts/package-native-deps.mjs b/apps/ade-cli/scripts/package-native-deps.mjs index 310b58533..15eb2158f 100644 --- a/apps/ade-cli/scripts/package-native-deps.mjs +++ b/apps/ade-cli/scripts/package-native-deps.mjs @@ -208,12 +208,19 @@ async function writeManifest(bundleRoot, target, packages) { await fs.writeFile(path.join(bundleRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); } -// Targets shipped as the production brain, where cr-sqlite is mandatory. A -// missing extension for one of these would silently re-ship the exact -// crsql_internal_sync_bit crash this packaging step exists to prevent, so it's -// a hard build failure rather than a warning. Other targets (not yet vendored) -// warn-and-skip until their extension is added. +// Sync-peer targets, where cr-sqlite is mandatory. A missing extension for one +// of these would silently re-ship the exact crsql_internal_sync_bit crash this +// packaging step exists to prevent, so it's a hard build failure rather than a +// warning. +// +// Linux is deliberately absent and is not a pending TODO. A Linux host is a +// remote runtime target that a macOS or Windows desktop drives over SSH; it is +// not a sync peer, holds no CRR state, and ships without the extension by +// design. Its brain logs `db.crsqlite_unavailable` and disables CRR triggers at +// startup. Adding linux-x64 here without also vendoring crsqlite.so would break +// every Linux runtime build. const CRSQLITE_REQUIRED_TARGETS = new Set(["darwin-arm64", "darwin-x64", "win32-x64"]); +const CRSQLITE_EXCLUDED_TARGETS = new Set(["linux-x64", "linux-arm64"]); function crsqliteExtensionFileName(target) { const { platform } = targetParts(target); @@ -241,6 +248,15 @@ async function copyCrsqliteExtension(bundleRoot, target) { `apps/desktop/vendor/crsqlite/${target}/.`, ); } + if (CRSQLITE_EXCLUDED_TARGETS.has(target)) { + // Expected and intentional: this target is a remote runtime host, not a + // sync peer. Stated as a scope note so it does not read as a build defect. + process.stdout.write( + `[package-native-deps] ${target} ships without cr-sqlite by design: it is a remote ` + + `runtime target, not a sync peer, and holds no CRR state.\n`, + ); + return false; + } process.stderr.write( `[package-native-deps] WARNING: no cr-sqlite extension vendored for ${target} ` + `(${source}); the installed brain on this target will lack CRDT sync.\n`, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ab67ca2d2..1a12a085b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -410,7 +410,7 @@ ADE uses Node's native `node:sqlite` driver (no better-sqlite3 dependency) with - **Database file**: `/.ade/ade.db`. - **WAL mode**: `openRawDatabase` sets `PRAGMA journal_mode = WAL` + `PRAGMA synchronous = NORMAL` at open. `flushNow()` forces pending WAL frames onto the main file with a `wal_checkpoint(TRUNCATE)` (used before shutdown and after a vacuum). - **CRRs**: eligible tables are marked via `SELECT crsql_as_crr('table_name')` at startup. Virtual/internal tables (`sqlite_%`, `crsql_%`) are excluded. Marking is dynamic — new tables are picked up automatically unless excluded. -- **Native extension payloads**: macOS loads `crsqlite.dylib`, Linux loads `crsqlite.so`, and Windows x64 loads the vendored `crsqlite.dll`. Windows package validation loads the installed DLL into an in-memory `node:sqlite` database, converts a table to a CRR, writes one row, and requires at least one `crsql_changes` record. A missing/unloadable extension disables device sync and is surfaced in Connections with reinstall/restart guidance instead of remaining a log-only warning. +- **Native extension payloads**: macOS loads `crsqlite.dylib` and Windows x64 loads the vendored `crsqlite.dll`. **No extension is vendored for Linux**, so a Linux host is a remote runtime target only — it can run work driven by a macOS or Windows desktop, but it is not a sync peer and holds no CRR state. Its brain logs `db.crsqlite_unavailable` and disables CRR triggers at startup. Windows package validation loads the installed DLL into an in-memory `node:sqlite` database, converts a table to a CRR, writes one row, and requires at least one `crsql_changes` record. A missing/unloadable extension disables device sync and is surfaced in Connections with reinstall/restart guidance instead of remaining a log-only warning. - **Sync API** (`AdeDb.sync`): `getSiteId()`, `getDbVersion()`, `exportChangesSince(version, { maxRows?, throughDbVersion?, excludeTables?, rejectOversizedVersionGroup? })`, `applyChanges(changes)`. Used by the sync transport. - **Merge semantics**: last-writer-wins per column with Lamport timestamps; each device has a site ID at `.ade/secrets/sync-site-id`. - **Engineering rule under CRR retrofit**: app-level `ON CONFLICT(...)` upserts must target PK only; secondary UNIQUE constraints do not survive CRR marking. diff --git a/docs/features/sync-and-multi-device/crdt-model.md b/docs/features/sync-and-multi-device/crdt-model.md index 9a1d86b3c..8d7befc94 100644 --- a/docs/features/sync-and-multi-device/crdt-model.md +++ b/docs/features/sync-and-multi-device/crdt-model.md @@ -56,9 +56,12 @@ ade-cli modules — there is no second implementation to keep in sync. ## Desktop / daemon: native loadable extension Both the Electron main process and the `ade serve` daemon open SQLite -through `node:sqlite` and load a vendored `crsqlite.dylib` (macOS), -`crsqlite.so` (Linux), or `crsqlite.dll` (Windows x64) as a loadable -extension. A fresh connection runs +through `node:sqlite` and load a vendored `crsqlite.dylib` (macOS) or +`crsqlite.dll` (Windows x64) as a loadable extension. No extension is +vendored for Linux: a Linux host is a remote runtime target that a macOS or +Windows desktop drives, not a sync peer. Its brain logs +`db.crsqlite_unavailable`, disables CRR triggers, and holds no replicated +state. A fresh connection runs `SELECT load_extension(...)` once, then `AdeDb` marks every eligible non-virtual table as a CRR at startup: From 199324a421b55016128e2e31eba0e41740c053e5 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 19:24:38 -0400 Subject: [PATCH 17/27] fix(release): sign Windows through Azure Artifact Signing The Windows packaging path assumed a password-protected PFX delivered through WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD. That model cannot exist: since June 2023 CA/Browser Forum rules require code-signing private keys to live on FIPS-validated hardware, exportable .pfx delivery ended for OV and EV alike, and Azure Artifact Signing never releases the certificate at all - it is held in the service and reachable only at the moment of signing. Neither secret is set in this repository and neither ever will be. Rewire run-electron-builder.mjs onto electron-builder 26's native win.azureSignOptions. electron-builder selects the Azure signing manager above the single chokepoint every Windows artifact passes through, so one configuration covers the packaged channel executable, its bundled DLLs, the NSIS installer, and the uninstaller. A post-build signing step could only reach the finished installer, leaving the executable already embedded inside it unsigned. The signed path now requires the AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET triple that Azure.Identity's EnvironmentCredential reads, and hands it to electron-builder only on that path so an unsigned dist:win can never reach the signing service. EnvironmentCredential is first in the DefaultAzureCredential chain, so a complete triple is resolved before any managed-identity probe against an instance-metadata endpoint a GitHub-hosted runner does not have. Pin the publisher by certificate Subject and nothing else. The service renews its certificate daily and expires it after 72 hours, so a pinned thumbprint would fail every release within days. WINDOWS_SIGNING_EXPECTED_THUMBPRINT is therefore refused outright rather than ignored, so a pin cannot quietly stop pinning. The same Subject is passed as electron-builder's publisherName, which electron-updater parses as a Distinguished Name before running a downloaded installer, so the updater and the release validator agree on one publisher. validate-win-artifacts.mjs stays exactly as strict: valid Authenticode status, a trusted RFC3161 timestamp, the pinned Subject, and one certificate shared by the installer and the ADE.exe it installs. Based-on: nsxdavid/ADE#999 (cherry picked from commit d49aa1ec698604a77d4cc54b52afd7f9a416bd17) --- apps/desktop/scripts/run-electron-builder.mjs | 96 ++++++++++-- .../scripts/validate-win-artifacts.mjs | 39 +++-- .../scripts/windows-release-contract.test.mjs | 145 +++++++++++++++--- 3 files changed, 226 insertions(+), 54 deletions(-) diff --git a/apps/desktop/scripts/run-electron-builder.mjs b/apps/desktop/scripts/run-electron-builder.mjs index e359979f6..2e52de0d9 100644 --- a/apps/desktop/scripts/run-electron-builder.mjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -20,19 +20,47 @@ const configuredRepository = ( const repositoryMatch = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(configuredRepository); const channelIdentity = resolveWindowsPackageIdentity(process.env.ADE_PACKAGE_CHANNEL); const { packageChannel } = channelIdentity; -const canonicalWindowsCscLink = process.env.WINDOWS_CSC_LINK; -const canonicalWindowsCscKeyPassword = process.env.WINDOWS_CSC_KEY_PASSWORD; +// Windows code signing runs against Azure Artifact Signing (the service +// formerly called Trusted Signing). There is no PFX to carry: CA/Browser Forum +// rules have required code-signing private keys to live on FIPS-validated +// hardware since June 2023, and this service never releases the certificate - +// it is held in the service and reachable only at the moment of signing. The +// only signing material the build sees is a Microsoft Entra service principal. +const AZURE_SIGNING_CREDENTIAL_ENV = ["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"]; +// Non-secret account coordinates. They are pinned here, not in CI, so the same +// values are used by a maintainer running dist:win:signed locally; each one is +// still overridable for a fork or a migrated account. +const azureSigningEndpoint = + process.env.WINDOWS_SIGNING_ENDPOINT?.trim() || "https://eus.codesigning.azure.net"; +const azureSigningAccountName = + process.env.WINDOWS_SIGNING_ACCOUNT_NAME?.trim() || "arulsigning"; +const azureCertificateProfileName = + process.env.WINDOWS_SIGNING_CERTIFICATE_PROFILE?.trim() || "adePublicTrust"; +// One pinned publisher value serves two jobs: electron-builder writes it into +// app-update.yml as the publisherName electron-updater checks before it runs a +// downloaded installer, and validate-win-artifacts.mjs asserts the same string +// against the Authenticode signer of the built artifacts. electron-updater +// parses it as a Distinguished Name and warns when it is given only a CN, so +// this must be the complete Subject. +const expectedSigningSubject = process.env.WINDOWS_SIGNING_EXPECTED_SUBJECT?.trim() ?? ""; const configuredFileAssociation = Array.isArray(pkg.build?.fileAssociations) ? pkg.build.fileAssociations[0] : pkg.build?.fileAssociations; if (!configuredFileAssociation || !Array.isArray(configuredFileAssociation.ext)) { throw new Error("Windows packaging requires the configured ADE file association extension list."); } +// CSC_LINK/CSC_KEY_PASSWORD are the macOS Developer ID secrets. electron-builder +// reads them on Windows too, so they are stripped unconditionally rather than +// left to be picked up as an unexpected Windows signing identity. The Azure +// credentials are stripped from the base environment for the same reason and +// handed back only on the signed path, so an unsigned dist:win can never reach +// the signing service. const baseChildEnv = { ...process.env }; delete baseChildEnv.CSC_LINK; delete baseChildEnv.CSC_KEY_PASSWORD; -delete baseChildEnv.WINDOWS_CSC_LINK; -delete baseChildEnv.WINDOWS_CSC_KEY_PASSWORD; +for (const name of AZURE_SIGNING_CREDENTIAL_ENV) { + delete baseChildEnv[name]; +} if (!repositoryMatch) { throw new Error( @@ -41,14 +69,30 @@ if (!repositoryMatch) { } if (requireSigning) { - const missingSecrets = ["WINDOWS_CSC_LINK", "WINDOWS_CSC_KEY_PASSWORD"] - .filter((name) => !process.env[name]?.trim()); + const missingSecrets = AZURE_SIGNING_CREDENTIAL_ENV.filter((name) => !process.env[name]?.trim()); if (missingSecrets.length > 0) { throw new Error( - `Signed Windows packaging requires ${missingSecrets.join(" and ")}. ` + `Signed Windows packaging requires ${missingSecrets.join(", ")}. ` + "Unsigned artifacts are allowed only through npm run dist:win.", ); } + if (!expectedSigningSubject) { + throw new Error( + "Signed Windows packaging requires WINDOWS_SIGNING_EXPECTED_SUBJECT, the complete certificate " + + "Subject of the Azure Artifact Signing certificate profile, so the packaged updater and the " + + "release validator pin the same publisher.", + ); + } + // Azure Artifact Signing renews its certificate daily and expires it after 72 + // hours, so a pinned thumbprint stops matching within days. Reject the name + // instead of ignoring it, so nobody sets it and believes it pinned something. + if (process.env.WINDOWS_SIGNING_EXPECTED_THUMBPRINT?.trim()) { + throw new Error( + "WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline. " + + "The service renews its certificate daily and expires it after 72 hours, so a pinned thumbprint " + + "would fail every release within days. Unset it and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead.", + ); + } } const [, owner, repo] = repositoryMatch; @@ -74,21 +118,47 @@ const args = [ `--config.fileAssociations.name=${channelIdentity.fileClass}`, `--config.fileAssociations.description=${configuredFileAssociation.description ?? "ADE files"}`, ...configuredFileAssociation.ext.map((extension) => `--config.fileAssociations.ext=${extension}`), - ...(requireSigning ? ["--config.forceCodeSigning=true"] : []), + // electron-builder 26 selects the Azure signing manager purely on the + // presence of win.azureSignOptions, and that selection sits above the single + // signIf() chokepoint every Windows artifact passes through - the packaged + // channel executable and its bundled DLLs, the NSIS installer, and the + // uninstaller. That is why the service is wired in here rather than as a + // separate post-build workflow step: a step that ran after packaging could + // only sign the installer, leaving the ADE.exe already embedded inside it + // unsigned unless the installer were unpacked and rebuilt. + ...(requireSigning + ? [ + "--config.forceCodeSigning=true", + `--config.win.azureSignOptions.publisherName=${expectedSigningSubject}`, + `--config.win.azureSignOptions.endpoint=${azureSigningEndpoint}`, + `--config.win.azureSignOptions.codeSigningAccountName=${azureSigningAccountName}`, + `--config.win.azureSignOptions.certificateProfileName=${azureCertificateProfileName}`, + "--config.win.azureSignOptions.fileDigest=SHA256", + // Timestamping is not optional here. The signing certificate is valid + // for 72 hours, so without an RFC3161 countersignature every shipped + // installer would stop verifying three days after it was built. + "--config.win.azureSignOptions.timestampRfc3161=http://timestamp.acs.microsoft.com", + "--config.win.azureSignOptions.timestampDigest=SHA256", + ] + : []), ]; console.log( - `[windows-package] Building ${channelIdentity.productName} for ${owner}/${repo}${requireSigning ? " with required Authenticode signing" : " (unsigned allowed)"}.`, + `[windows-package] Building ${channelIdentity.productName} for ${owner}/${repo}${requireSigning ? " with required Azure Artifact Signing" : " (unsigned allowed)"}.`, ); const childEnv = { ...baseChildEnv, ADE_PACKAGE_CHANNEL: packageChannel === "stable" ? "" : packageChannel, ADE_DESKTOP_APP_NAME: channelIdentity.productName, + // electron-builder authenticates to Microsoft Entra ID with Azure.Identity's + // EnvironmentCredential, which reads exactly these names. It is first in the + // credential chain, so a complete service-principal triple is resolved before + // any managed-identity probe against the Azure IMDS endpoint - which a + // GitHub-hosted runner does not have. ...(requireSigning - ? { - CSC_LINK: canonicalWindowsCscLink, - CSC_KEY_PASSWORD: canonicalWindowsCscKeyPassword, - } + ? Object.fromEntries( + AZURE_SIGNING_CREDENTIAL_ENV.map((name) => [name, process.env[name]]), + ) : {}), }; const electronBuilderCommand = process.platform === "win32" ? process.execPath : electronBuilderBin; diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 017b51e4f..9b9c723ad 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -92,19 +92,29 @@ function normalizeCertificateThumbprint(value) { return value?.replace(/\s+/g, "").toUpperCase() ?? ""; } +// Azure Artifact Signing never releases the signing certificate: it mints a +// short-lived leaf per profile, renews it daily, and expires it after 72 hours. +// A pinned thumbprint would therefore reject every release within days, so the +// publisher pin is the certificate Subject and only the Subject. The thumbprint +// name is rejected outright rather than quietly ignored, so nobody sets it and +// then discovers days later that it pinned nothing. function expectedWindowsSigningIdentity() { if (!shouldRequireSignedArtifacts()) return null; const subject = process.env.WINDOWS_SIGNING_EXPECTED_SUBJECT?.trim() ?? ""; - const thumbprint = normalizeCertificateThumbprint( - process.env.WINDOWS_SIGNING_EXPECTED_THUMBPRINT, - ); - if (!subject && !thumbprint) { + if (process.env.WINDOWS_SIGNING_EXPECTED_THUMBPRINT?.trim()) { + fail( + "WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline. " + + "The service renews its certificate daily and expires it after 72 hours, so a pinned thumbprint " + + "would fail every release within days. Unset it and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead.", + ); + } + if (!subject) { fail( "Signed Windows validation requires WINDOWS_SIGNING_EXPECTED_SUBJECT " + - "or WINDOWS_SIGNING_EXPECTED_THUMBPRINT so the release cannot be signed by an unexpected publisher.", + "so the release cannot be signed by an unexpected publisher.", ); } - return { subject, thumbprint }; + return { subject }; } function resolveAbsolute(input) { @@ -929,21 +939,12 @@ async function validateAuthenticodeSignature(filePath, description, expectedIden if (!identity.subject || !identity.thumbprint) { fail(`${description} has no readable Authenticode signer identity`); } - if ( - expectedIdentity.subject - && identity.subject.toLocaleLowerCase("en-US") !== expectedIdentity.subject.toLocaleLowerCase("en-US") - ) { + if (identity.subject.toLocaleLowerCase("en-US") !== expectedIdentity.subject.toLocaleLowerCase("en-US")) { fail( `${description} was signed by an unexpected publisher. ` + `Expected "${expectedIdentity.subject}", received "${identity.subject}".`, ); } - if (expectedIdentity.thumbprint && identity.thumbprint !== expectedIdentity.thumbprint) { - fail( - `${description} was signed by an unexpected certificate thumbprint. ` + - `Expected ${expectedIdentity.thumbprint}, received ${identity.thumbprint}.`, - ); - } return identity; } @@ -981,6 +982,12 @@ async function validateReleaseArtifacts() { "packaged Windows app executable", expectedIdentity, ); + // Both artifacts are pinned to the same Subject above, but a Subject match + // alone would still accept two different certificates carrying that Subject. + // Requiring one thumbprint across the pair proves the installer and the + // executable it installs came from the same signing operation. Azure Artifact + // Signing rotates the leaf daily, so a build that straddles a rotation is the + // one legitimate way this can trip; rerun the build rather than relaxing it. if ( installerIdentity && appIdentity diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index 06e2e2168..ea713ae22 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -138,11 +138,14 @@ test("public Windows packaging fails closed on Authenticode signing", () => { assert.match(windowsRelease, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof/); assert.match(windowsRelease, /npm run dist:win:signed/); assert.match(windowsRelease, /ADE_RELEASE_REPOSITORY:\s*\$\{\{ github\.repository \}\}/); - assert.match(windowsRelease, /WINDOWS_CSC_LINK/); - assert.match(windowsRelease, /WINDOWS_CSC_KEY_PASSWORD/); - assert.doesNotMatch(windowsRelease, /WIN_CSC_LINK|WIN_CSC_KEY_PASSWORD/); + // Azure Artifact Signing holds the private key and never releases the + // certificate, so the only signing material the job carries is a Microsoft + // Entra service principal. No PFX secret exists to reference any more. + assert.match(windowsRelease, /AZURE_TENANT_ID: \$\{\{ secrets\.AZURE_TENANT_ID \}\}/); + assert.match(windowsRelease, /AZURE_CLIENT_ID: \$\{\{ secrets\.AZURE_CLIENT_ID \}\}/); + assert.match(windowsRelease, /AZURE_CLIENT_SECRET: \$\{\{ secrets\.AZURE_CLIENT_SECRET \}\}/); + assert.doesNotMatch(releaseWorkflow, /WIN_CSC_LINK|WINDOWS_CSC_LINK|WINDOWS_CSC_KEY_PASSWORD/); assert.match(windowsRelease, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); - assert.match(windowsRelease, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT/); assert.doesNotMatch(windowsRelease, /ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT|ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT/); assert.match(windowsRelease, /ADE_POSTHOG_PROJECT_TOKEN:\s*\$\{\{ secrets\.ADE_POSTHOG_PROJECT_TOKEN \}\}/); assert.match(windowsRelease, /ADE_POSTHOG_HOST:\s*\$\{\{ secrets\.ADE_POSTHOG_HOST \}\}/); @@ -155,26 +158,108 @@ test("public Windows packaging fails closed on Authenticode signing", () => { test("signed packaging stops before electron-builder when credentials are absent", () => { const wrapper = path.join(desktopRoot, "scripts", "run-electron-builder.mjs"); const env = { ...process.env }; - delete env.WINDOWS_CSC_LINK; - delete env.WINDOWS_CSC_KEY_PASSWORD; + delete env.AZURE_TENANT_ID; + delete env.AZURE_CLIENT_ID; + delete env.AZURE_CLIENT_SECRET; const result = spawnSync(process.execPath, [wrapper, "--require-signing", "--win", "--x64"], { cwd: desktopRoot, env, encoding: "utf8", }); assert.notEqual(result.status, 0); - assert.match(`${result.stdout}\n${result.stderr}`, /Signed Windows packaging requires WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD/); + assert.match( + `${result.stdout}\n${result.stderr}`, + /Signed Windows packaging requires AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET/, + ); }); -test("Windows packaging accepts signing material only through canonical inputs", () => { - assert.match(electronBuilderWrapper, /canonicalWindowsCscLink = process\.env\.WINDOWS_CSC_LINK/); - assert.match(electronBuilderWrapper, /canonicalWindowsCscKeyPassword = process\.env\.WINDOWS_CSC_KEY_PASSWORD/); +test("signed packaging stops before electron-builder when the publisher is unpinned", () => { + const wrapper = path.join(desktopRoot, "scripts", "run-electron-builder.mjs"); + const env = { + ...process.env, + AZURE_TENANT_ID: "tenant", + AZURE_CLIENT_ID: "client", + AZURE_CLIENT_SECRET: "secret", + }; + delete env.WINDOWS_SIGNING_EXPECTED_SUBJECT; + const result = spawnSync(process.execPath, [wrapper, "--require-signing", "--win", "--x64"], { + cwd: desktopRoot, + env, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match( + `${result.stdout}\n${result.stderr}`, + /Signed Windows packaging requires WINDOWS_SIGNING_EXPECTED_SUBJECT/, + ); +}); + +// Azure Artifact Signing renews the certificate daily and expires it after 72 +// hours, so thumbprint pinning would fail every release within days. Every +// layer that could accept the name must reject it instead of ignoring it. +test("thumbprint pinning is rejected everywhere rather than silently ignored", () => { + const wrapper = path.join(desktopRoot, "scripts", "run-electron-builder.mjs"); + const result = spawnSync(process.execPath, [wrapper, "--require-signing", "--win", "--x64"], { + cwd: desktopRoot, + env: { + ...process.env, + AZURE_TENANT_ID: "tenant", + AZURE_CLIENT_ID: "client", + AZURE_CLIENT_SECRET: "secret", + WINDOWS_SIGNING_EXPECTED_SUBJECT: "CN=Example", + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: "0123456789ABCDEF", + }, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match( + `${result.stdout}\n${result.stderr}`, + /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/, + ); + assert.match(winArtifactValidator, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/); + assert.match(windowsRuntimeSigner, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/); + assert.match(releaseWorkflow, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/); + assert.match(prepareWorkflow, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/); + // The subject is now the only pin, so the validator must require it outright + // rather than accepting either name. + assert.doesNotMatch( + winArtifactValidator, + /WINDOWS_SIGNING_EXPECTED_SUBJECT\s*"?\s*\+?\s*"?\s*or WINDOWS_SIGNING_EXPECTED_THUMBPRINT/, + ); + assert.doesNotMatch(winArtifactValidator, /expectedIdentity\.thumbprint &&/); +}); + +test("Windows packaging signs through Azure Artifact Signing and no local key material", () => { + // electron-builder 26 picks the Azure signing manager purely on the presence + // of win.azureSignOptions, above the single chokepoint that signs the + // packaged executable, its DLLs, the NSIS installer, and the uninstaller. + assert.match(electronBuilderWrapper, /--config\.win\.azureSignOptions\.publisherName=\$\{expectedSigningSubject\}/); + assert.match(electronBuilderWrapper, /--config\.win\.azureSignOptions\.endpoint=\$\{azureSigningEndpoint\}/); + assert.match( + electronBuilderWrapper, + /--config\.win\.azureSignOptions\.codeSigningAccountName=\$\{azureSigningAccountName\}/, + ); + assert.match( + electronBuilderWrapper, + /--config\.win\.azureSignOptions\.certificateProfileName=\$\{azureCertificateProfileName\}/, + ); + // The certificate lives for 72 hours, so an RFC3161 countersignature is what + // keeps a shipped installer verifiable afterwards. + assert.match( + electronBuilderWrapper, + /--config\.win\.azureSignOptions\.timestampRfc3161=http:\/\/timestamp\.acs\.microsoft\.com/, + ); + assert.match(electronBuilderWrapper, /--config\.forceCodeSigning=true/); + // The macOS Developer ID secrets must never become a Windows signing + // identity, and an unsigned dist:win must never reach the signing service. assert.match(electronBuilderWrapper, /delete baseChildEnv\.CSC_LINK/); assert.match(electronBuilderWrapper, /delete baseChildEnv\.CSC_KEY_PASSWORD/); - assert.match(electronBuilderWrapper, /delete baseChildEnv\.WINDOWS_CSC_LINK/); - assert.match(electronBuilderWrapper, /delete baseChildEnv\.WINDOWS_CSC_KEY_PASSWORD/); - assert.match(electronBuilderWrapper, /CSC_LINK: canonicalWindowsCscLink/); - assert.match(electronBuilderWrapper, /CSC_KEY_PASSWORD: canonicalWindowsCscKeyPassword/); + assert.match( + electronBuilderWrapper, + /AZURE_SIGNING_CREDENTIAL_ENV = \["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"\]/, + ); + assert.match(electronBuilderWrapper, /for \(const name of AZURE_SIGNING_CREDENTIAL_ENV\) \{\s*\n\s*delete baseChildEnv\[name\];/); + assert.doesNotMatch(electronBuilderWrapper, /CSC_LINK:|CSC_KEY_PASSWORD:|\.pfx|WINDOWS_CSC_/); }); test("Windows packaging rejects unknown channels before electron-builder", () => { @@ -253,19 +338,29 @@ test("standalone Windows runtime signing uses only canonical credentials and val assert.match(runtimeBuild, /target: win32-x64[\s\S]*os: windows-latest[\s\S]*binary: ade-win32-x64\.exe/); assert.match(windowsSignStep, /matrix\.target == 'win32-x64'/); assert.match(windowsSignStep, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof/); - assert.match(windowsSignStep, /WINDOWS_CSC_LINK: \$\{\{ secrets\.WINDOWS_CSC_LINK \}\}/); - assert.match(windowsSignStep, /WINDOWS_CSC_KEY_PASSWORD: \$\{\{ secrets\.WINDOWS_CSC_KEY_PASSWORD \}\}/); + assert.match(windowsSignStep, /AZURE_TENANT_ID: \$\{\{ secrets\.AZURE_TENANT_ID \}\}/); + assert.match(windowsSignStep, /AZURE_CLIENT_ID: \$\{\{ secrets\.AZURE_CLIENT_ID \}\}/); + assert.match(windowsSignStep, /AZURE_CLIENT_SECRET: \$\{\{ secrets\.AZURE_CLIENT_SECRET \}\}/); assert.match(windowsSignStep, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); - assert.match(windowsSignStep, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT/); - assert.doesNotMatch(windowsSignStep, /(?:^|\s)(?:WIN_CSC_LINK|WIN_CSC_KEY_PASSWORD|CSC_LINK|CSC_KEY_PASSWORD):/m); + assert.doesNotMatch(windowsSignStep, /(?:^|\s)(?:WIN_CSC_LINK|WIN_CSC_KEY_PASSWORD|CSC_LINK|CSC_KEY_PASSWORD|WINDOWS_CSC_LINK|WINDOWS_CSC_KEY_PASSWORD):/m); assert.match(windowsSignStep, /sign-windows-runtime\.ps1/); - assert.match(windowsRuntimeSigner, /Set-AuthenticodeSignature/); + // The standalone runtime signs through the same Azure Artifact Signing + // mechanism electron-builder 26 uses, so there is one signing code path for + // every Windows artifact ADE publishes. + assert.match(windowsRuntimeSigner, /Invoke-TrustedSigning/); + assert.match(windowsRuntimeSigner, /-CodeSigningAccountName \$signingAccountName/); + assert.match(windowsRuntimeSigner, /-CertificateProfileName \$certificateProfileName/); + assert.match(windowsRuntimeSigner, /timestamp\.acs\.microsoft\.com/); + // Post-sign verification stays exactly as strict: valid status, a trusted + // RFC3161 timestamp, and the pinned publisher subject. assert.match(windowsRuntimeSigner, /Get-AuthenticodeSignature/); + assert.match(windowsRuntimeSigner, /SignatureStatus\]::Valid/); assert.match(windowsRuntimeSigner, /TimeStamperCertificate/); assert.match(windowsRuntimeSigner, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); - assert.match(windowsRuntimeSigner, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT/); - assert.match(windowsRuntimeSigner, /X509KeyStorageFlags\]::EphemeralKeySet/); - assert.doesNotMatch(windowsRuntimeSigner, /Write-Output.*(?:certificateSource|certificatePassword|expectedSubject|expectedThumbprint)/); + // The service never releases the certificate, so the signer must not contain + // any local key-material handling at all. + assert.doesNotMatch(windowsRuntimeSigner, /X509Certificate2|\.pfx|WINDOWS_CSC_|Set-AuthenticodeSignature/); + assert.doesNotMatch(windowsRuntimeSigner, /Write-Output.*(?:AZURE_CLIENT_SECRET|expectedSubject)/); }); test("standalone Windows release assets remain behind the publication gate", () => { @@ -325,8 +420,8 @@ test("Windows release assets are validated and published as one release set", () assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'[\s\S]*needs\.build-win-release\.result == 'success'/); // verify fails fast on missing signing material instead of on stale proof // bindings, which no longer exist. - assert.match(verify, /Windows releases require the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD secrets/); - assert.match(verify, /WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT secret to pin the approved publisher/); + assert.match(verify, /Windows releases require the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets/); + assert.match(verify, /Windows releases require the WINDOWS_SIGNING_EXPECTED_SUBJECT secret to pin the approved publisher/); assert.match(verify, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); assert.match(publish, /if: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \}\}/); assert.match(publish, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); @@ -405,7 +500,7 @@ test("release preflight validates the exact approved commit", () => { test("Windows proof collection is opt-in, non-publishing, and emits an exact-SHA manifest", () => { const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); assert.match(prepareWorkflow, /name: Prepare signed Windows proof/); - assert.match(prepareWorkflow, /Signed Windows proof requires the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD secrets/); + assert.match(prepareWorkflow, /Signed Windows proof requires the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets/); assert.match(prepareWorkflow, /windows_proof: \$\{\{ inputs\.windows_proof \}\}/); assert.match(prepareWorkflow, /publish: false/); assert.doesNotMatch(prepareWorkflow, /contents: write/); From e6e7a5dab58d9894926d93081d45b27cfa1e64bf Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 19:24:52 -0400 Subject: [PATCH 18/27] fix(release): sign the standalone Windows runtime through Azure Artifact Signing The standalone runtime signer still imported a PFX from WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD, so it would have failed for the same reason the desktop packaging path did: there is no exportable certificate to import, and Azure Artifact Signing never releases one. Sign through Invoke-TrustedSigning, the same mechanism electron-builder 26 uses for the desktop installer, so both Windows artifacts ADE publishes come from one signing path with one set of parameter names. All local key-material handling is gone; the only credential the script carries is the Entra service principal. Verification is unchanged in strictness: valid Authenticode status, a trusted RFC3161 timestamp against the service's own timestamp authority, and the pinned publisher Subject. WINDOWS_SIGNING_EXPECTED_THUMBPRINT is refused rather than ignored, because a certificate that expires after 72 hours cannot be pinned by thumbprint. Based-on: nsxdavid/ADE#999 (cherry picked from commit d8c26adf40837716d0ecd8e62a5498afc58d2623) --- apps/ade-cli/scripts/sign-windows-runtime.ps1 | 141 +++++++++--------- 1 file changed, 70 insertions(+), 71 deletions(-) diff --git a/apps/ade-cli/scripts/sign-windows-runtime.ps1 b/apps/ade-cli/scripts/sign-windows-runtime.ps1 index 8900fa22f..f85bc2dd6 100644 --- a/apps/ade-cli/scripts/sign-windows-runtime.ps1 +++ b/apps/ade-cli/scripts/sign-windows-runtime.ps1 @@ -2,7 +2,9 @@ param( [Parameter(Mandatory = $true)] [string]$BinaryPath, - [string]$TimestampServer = "http://timestamp.digicert.com" + # Azure Artifact Signing certificates are valid for 72 hours, so an RFC3161 + # countersignature is what keeps a released binary verifiable past that. + [string]$TimestampServer = "http://timestamp.acs.microsoft.com" ) $ErrorActionPreference = "Stop" @@ -12,86 +14,83 @@ function Fail([string]$Message) { throw "ADE Windows runtime signing: $Message" } -function Normalize-Thumbprint([string]$Value) { - return ($Value -replace '\s+', '').ToUpperInvariant() -} - $resolvedBinary = [IO.Path]::GetFullPath($BinaryPath) if (-not (Test-Path -LiteralPath $resolvedBinary -PathType Leaf)) { Fail "runtime binary is missing: $resolvedBinary" } -$certificateSource = [string]$env:WINDOWS_CSC_LINK -$certificatePassword = [string]$env:WINDOWS_CSC_KEY_PASSWORD +# The signing key is held by Azure Artifact Signing and never leaves it, so the +# only credential this script carries is a Microsoft Entra service principal. +# These three names are exactly what Azure.Identity's EnvironmentCredential +# reads, and EnvironmentCredential is tried before any managed-identity probe, +# which a GitHub-hosted runner cannot satisfy. +$missingCredentials = @( + "AZURE_TENANT_ID", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET" +) | Where-Object { [string]::IsNullOrWhiteSpace([string][Environment]::GetEnvironmentVariable($_)) } +if ($missingCredentials.Count -gt 0) { + Fail "$($missingCredentials -join ', ') are required to sign with Azure Artifact Signing" +} + +$signingEndpoint = ([string]$env:WINDOWS_SIGNING_ENDPOINT).Trim() +if ([string]::IsNullOrWhiteSpace($signingEndpoint)) { + $signingEndpoint = "https://eus.codesigning.azure.net" +} +$signingAccountName = ([string]$env:WINDOWS_SIGNING_ACCOUNT_NAME).Trim() +if ([string]::IsNullOrWhiteSpace($signingAccountName)) { + $signingAccountName = "arulsigning" +} +$certificateProfileName = ([string]$env:WINDOWS_SIGNING_CERTIFICATE_PROFILE).Trim() +if ([string]::IsNullOrWhiteSpace($certificateProfileName)) { + $certificateProfileName = "adePublicTrust" +} + $expectedSubject = ([string]$env:WINDOWS_SIGNING_EXPECTED_SUBJECT).Trim() -$expectedThumbprint = Normalize-Thumbprint ([string]$env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT) -if ([string]::IsNullOrWhiteSpace($certificateSource) -or [string]::IsNullOrWhiteSpace($certificatePassword)) { - Fail "WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD are required" +if ([string]::IsNullOrWhiteSpace($expectedSubject)) { + Fail "WINDOWS_SIGNING_EXPECTED_SUBJECT is required so the runtime cannot be signed by an unexpected publisher" } -if ([string]::IsNullOrWhiteSpace($expectedSubject) -and [string]::IsNullOrWhiteSpace($expectedThumbprint)) { - Fail "WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT is required" +# Azure Artifact Signing renews its certificate daily and expires it after 72 +# hours. A pinned thumbprint would reject every release within days, so the name +# is rejected rather than ignored - an ignored pin is a pin nobody notices is gone. +if (-not [string]::IsNullOrWhiteSpace(([string]$env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT).Trim())) { + Fail "WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline; the service renews its certificate daily and expires it after 72 hours. Unset it and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead" } -$certificatePath = Join-Path ([IO.Path]::GetTempPath()) ("ade-runtime-signing-" + [Guid]::NewGuid().ToString("N") + ".pfx") -try { - $trimmedSource = $certificateSource.Trim() - if (Test-Path -LiteralPath $trimmedSource -PathType Leaf) { - Copy-Item -LiteralPath $trimmedSource -Destination $certificatePath - } elseif ($trimmedSource.StartsWith("file://", [StringComparison]::OrdinalIgnoreCase)) { - $sourceUri = [Uri]$trimmedSource - Copy-Item -LiteralPath $sourceUri.LocalPath -Destination $certificatePath - } elseif ($trimmedSource.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase)) { - Invoke-WebRequest -UseBasicParsing -Uri $trimmedSource -OutFile $certificatePath - } elseif ($trimmedSource.StartsWith("http://", [StringComparison]::OrdinalIgnoreCase)) { - Fail "WINDOWS_CSC_LINK must use HTTPS, a local path, or an encoded certificate payload" - } else { - try { - [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($trimmedSource)) - } catch { - Fail "WINDOWS_CSC_LINK is not a valid certificate path, HTTPS URL, or Base64 payload" - } +# Same signing mechanism electron-builder 26 uses for the desktop installer, so +# the desktop and standalone Windows artifacts go through one code path with one +# set of verified parameter names. Microsoft's successor module is +# `ArtifactSigning` (`Invoke-ArtifactSigning`), which the current GitHub Action +# uses; electron-builder 26.8.1 hardcodes `TrustedSigning` and only moves off it +# in v27, which replaces the module with `signtool /dlib`. Migrate both together +# when the desktop build moves, so the two Windows artifacts never diverge. +if (-not (Get-Module -ListAvailable -Name TrustedSigning)) { + if ($null -eq (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null } + Install-Module -Name TrustedSigning -MinimumVersion 0.5.0 -Force -Repository PSGallery -Scope CurrentUser +} +Import-Module TrustedSigning -Force - $flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet - $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( - [IO.File]::ReadAllBytes($certificatePath), - $certificatePassword, - $flags - ) - try { - if (-not $certificate.HasPrivateKey) { - Fail "the configured certificate has no private key" - } - - $signingResult = Set-AuthenticodeSignature ` - -LiteralPath $resolvedBinary ` - -Certificate $certificate ` - -HashAlgorithm SHA256 ` - -TimestampServer $TimestampServer - if ($signingResult.Status -ne [Management.Automation.SignatureStatus]::Valid) { - Fail "runtime signing failed with status $($signingResult.Status)" - } - - $signature = Get-AuthenticodeSignature -LiteralPath $resolvedBinary - if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid) { - Fail "signed runtime validation failed with status $($signature.Status)" - } - if ($null -eq $signature.TimeStamperCertificate) { - Fail "signed runtime has no trusted Authenticode timestamp" - } - $actualSubject = ([string]$signature.SignerCertificate.Subject).Trim() - $actualThumbprint = Normalize-Thumbprint ([string]$signature.SignerCertificate.Thumbprint) - if ($expectedSubject -and -not [string]::Equals($actualSubject, $expectedSubject, [StringComparison]::OrdinalIgnoreCase)) { - Fail "signed runtime publisher subject does not match WINDOWS_SIGNING_EXPECTED_SUBJECT" - } - if ($expectedThumbprint -and -not [string]::Equals($actualThumbprint, $expectedThumbprint, [StringComparison]::Ordinal)) { - Fail "signed runtime certificate does not match WINDOWS_SIGNING_EXPECTED_THUMBPRINT" - } - } finally { - $certificate.Dispose() - } +Invoke-TrustedSigning ` + -Endpoint $signingEndpoint ` + -CodeSigningAccountName $signingAccountName ` + -CertificateProfileName $certificateProfileName ` + -Files $resolvedBinary ` + -FileDigest "SHA256" ` + -TimestampRfc3161 $TimestampServer ` + -TimestampDigest "SHA256" - Write-Output "Signed and validated the standalone Windows runtime." -} finally { - Remove-Item -LiteralPath $certificatePath -Force -ErrorAction SilentlyContinue +$signature = Get-AuthenticodeSignature -LiteralPath $resolvedBinary +if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid) { + Fail "signed runtime validation failed with status $($signature.Status)" } +if ($null -eq $signature.TimeStamperCertificate) { + Fail "signed runtime has no trusted Authenticode timestamp" +} +$actualSubject = ([string]$signature.SignerCertificate.Subject).Trim() +if (-not [string]::Equals($actualSubject, $expectedSubject, [StringComparison]::OrdinalIgnoreCase)) { + Fail "signed runtime publisher subject does not match WINDOWS_SIGNING_EXPECTED_SUBJECT" +} + +Write-Output "Signed and validated the standalone Windows runtime." From d76c3105af78baf9e6e5a47e75fd22277cfde5e4 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 19:24:52 -0400 Subject: [PATCH 19/27] fix(release): require Azure signing credentials instead of PFX secrets Both release workflows gated Windows on WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD, secrets that are not set and never will be. Replace that job-level requirement with the Azure Artifact Signing service principal: AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET, the three names Azure.Identity's EnvironmentCredential reads. Microsoft's guidance for runners outside Azure is exactly this triple, because the credential chain otherwise falls through to a managed-identity probe a GitHub-hosted runner cannot answer and raises CredentialUnavailableException. verify keeps failing about a minute in rather than after a full package build, and now also refuses to start while WINDOWS_SIGNING_EXPECTED_THUMBPRINT exists. The service renews its certificate daily and expires it after 72 hours, so leaving a thumbprint pin accepted-but-ignored would look like a pin while pinning nothing. The macOS and Linux release paths are untouched; CSC_LINK and CSC_KEY_PASSWORD remain the macOS Developer ID secrets. Based-on: nsxdavid/ADE#999 (cherry picked from commit d07d802b627b210bf9d7f47f6673c96b97ab3742) --- .github/workflows/prepare-release.yml | 17 +++++---- .github/workflows/release-core.yml | 51 +++++++++++++++++---------- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 6bb62d4fe..eba27d6c8 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -44,19 +44,24 @@ jobs: - name: Prepare signed Windows proof mode if: ${{ inputs.windows_proof }} env: - WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} - WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} run: | set -euo pipefail - if [ -z "$WINDOWS_CSC_LINK" ] || [ -z "$WINDOWS_CSC_KEY_PASSWORD" ]; then - echo "::error::Signed Windows proof requires the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD secrets." + if [ -z "$AZURE_TENANT_ID" ] || [ -z "$AZURE_CLIENT_ID" ] || [ -z "$AZURE_CLIENT_SECRET" ]; then + echo "::error::Signed Windows proof requires the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets." exit 1 fi - if [ -z "$WINDOWS_SIGNING_EXPECTED_SUBJECT" ] && [ -z "$WINDOWS_SIGNING_EXPECTED_THUMBPRINT" ]; then - echo "::error::Signed Windows proof requires the WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT secret." + if [ -z "$WINDOWS_SIGNING_EXPECTED_SUBJECT" ]; then + echo "::error::Signed Windows proof requires the WINDOWS_SIGNING_EXPECTED_SUBJECT secret." + exit 1 + fi + if [ -n "$WINDOWS_SIGNING_EXPECTED_THUMBPRINT" ]; then + echo "::error::WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline. Delete the secret and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead." exit 1 fi diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 300625854..7d6181452 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -105,8 +105,9 @@ jobs: env: PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} WINDOWS_PROOF: ${{ inputs.windows_proof }} - WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} - WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} run: | @@ -117,12 +118,19 @@ jobs: exit 0 fi - if [ -z "$WINDOWS_CSC_LINK" ] || [ -z "$WINDOWS_CSC_KEY_PASSWORD" ]; then - echo "::error::Windows releases require the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD secrets." + if [ -z "$AZURE_TENANT_ID" ] || [ -z "$AZURE_CLIENT_ID" ] || [ -z "$AZURE_CLIENT_SECRET" ]; then + echo "::error::Windows releases require the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets." exit 1 fi - if [ -z "$WINDOWS_SIGNING_EXPECTED_SUBJECT" ] && [ -z "$WINDOWS_SIGNING_EXPECTED_THUMBPRINT" ]; then - echo "::error::Windows releases require the WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT secret to pin the approved publisher." + if [ -z "$WINDOWS_SIGNING_EXPECTED_SUBJECT" ]; then + echo "::error::Windows releases require the WINDOWS_SIGNING_EXPECTED_SUBJECT secret to pin the approved publisher." + exit 1 + fi + # Azure Artifact Signing renews the certificate daily and expires it + # after 72 hours, so a thumbprint pin would start failing releases + # within days. Refuse the secret rather than ignore it. + if [ -n "$WINDOWS_SIGNING_EXPECTED_THUMBPRINT" ]; then + echo "::error::WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline. Delete the secret and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead." exit 1 fi @@ -336,16 +344,20 @@ jobs: - name: Require Windows Authenticode signing secrets shell: pwsh env: - WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} - WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} run: | - if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CSC_LINK) -or [string]::IsNullOrWhiteSpace($env:WINDOWS_CSC_KEY_PASSWORD)) { - throw "Public Windows releases require WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD." + if ([string]::IsNullOrWhiteSpace($env:AZURE_TENANT_ID) -or [string]::IsNullOrWhiteSpace($env:AZURE_CLIENT_ID) -or [string]::IsNullOrWhiteSpace($env:AZURE_CLIENT_SECRET)) { + throw "Public Windows releases require AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET." + } + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_SUBJECT)) { + throw "Public Windows releases require WINDOWS_SIGNING_EXPECTED_SUBJECT." } - if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_SUBJECT) -and [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT)) { - throw "Public Windows releases require WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT." + if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT)) { + throw "WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline; pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead." } - name: Download ADE runtime binaries @@ -378,10 +390,13 @@ jobs: ADE_RELEASE_REPOSITORY: ${{ github.repository }} ADE_POSTHOG_PROJECT_TOKEN: ${{ secrets.ADE_POSTHOG_PROJECT_TOKEN }} ADE_POSTHOG_HOST: ${{ secrets.ADE_POSTHOG_HOST }} - WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} - WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + # Azure Artifact Signing service principal. EnvironmentCredential + # reads exactly these three names and is tried ahead of the managed + # identity probe that a GitHub-hosted runner cannot answer. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} - WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} run: cd apps/desktop && npm run dist:win:signed - name: Test installed signed Windows product lifecycle @@ -575,10 +590,10 @@ jobs: if: ${{ matrix.target == 'win32-x64' && (vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' || inputs.windows_proof) }} shell: pwsh env: - WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} - WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} - WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} run: | & apps/ade-cli/scripts/sign-windows-runtime.ps1 ` -BinaryPath "apps/ade-cli/dist-static/${{ matrix.binary }}" From 1017abb2216f8d1fdf3430fda2ac4a2f54a8ba10 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 19:25:03 -0400 Subject: [PATCH 20/27] docs(release): rewrite the Windows signing playbook for Azure Artifact Signing The playbook documented a PFX flow that cannot exist and told maintainers to adapt the workflow themselves if they picked a service without one. The pipeline now signs through Azure Artifact Signing, so document that service and the exact resources behind it: the arulsigning account in rg-signing, the East US endpoint, the adePublicTrust certificate profile, and the ade-signing-ci service principal holding the Artifact Signing Certificate Profile Signer role. State plainly why there is no certificate to hold and why the publisher is pinned by Subject: the service holds the key, never releases the certificate, renews it daily, and expires it after 72 hours. Record that WINDOWS_SIGNING_EXPECTED_THUMBPRINT is refused at every layer rather than ignored, and that RFC3161 timestamping is what keeps a shipped installer verifiable past the certificate's three-day life. Correct the audited settings table to the four secrets that now exist, explain why electron-builder's native win.azureSignOptions was chosen over a post-build signing action, and add the failure modes a maintainer will actually hit: a 403 from a lost role assignment, a credential chain that fell past EnvironmentCredential, a build that straddled the daily certificate rotation, and a runner that could not reach PSGallery. Based-on: nsxdavid/ADE#999 (cherry picked from commit ce999120c28ecf3833cbd145cb8a00dc7f3c377d) --- docs/ARCHITECTURE.md | 5 +- docs/development/windows-port-lane.md | 2 +- docs/playbooks/windows-signed-release.md | 123 ++++++++++++++++++----- 3 files changed, 103 insertions(+), 27 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1a12a085b..675e52348 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1509,11 +1509,12 @@ macOS: Windows: - `npm run dist:win` — unsigned x64 preview installer via the guarded `run-electron-builder.mjs` wrapper, with `validate:win:artifacts` (preflight) and `validate:win:release` (post-build) checks in `apps/desktop/scripts/validate-win-artifacts.mjs`. -- `npm run dist:win:signed` — production path. The wrapper requires canonical `WINDOWS_CSC_LINK` and `WINDOWS_CSC_KEY_PASSWORD`, maps them only into the isolated electron-builder child process, and enables fail-closed code signing; post-build validation requires a trusted RFC3161 timestamp, the canonical `WINDOWS_SIGNING_EXPECTED_SUBJECT` and/or `WINDOWS_SIGNING_EXPECTED_THUMBPRINT` identity, and the same signer on the NSIS installer and installed channel executable. Those four `WINDOWS_*` names are the complete Windows signing-secret contract; the existing generic `CSC_*` secrets remain macOS-only. +- `npm run dist:win:signed` — production path, signing through [Azure Artifact Signing](https://learn.microsoft.com/en-us/azure/artifact-signing/overview). The service holds the private key and never releases the certificate, so there is no PFX: the wrapper requires the `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` service-principal triple that Azure.Identity's `EnvironmentCredential` reads, maps it only into the isolated electron-builder child process, and configures `win.azureSignOptions` plus fail-closed code signing. electron-builder 26 selects the Azure signing manager above the single chokepoint every Windows artifact passes through, so one configuration covers the channel executable, its DLLs, the NSIS installer, and the uninstaller. Post-build validation requires a trusted RFC3161 timestamp against `http://timestamp.acs.microsoft.com`, the canonical `WINDOWS_SIGNING_EXPECTED_SUBJECT` identity, and the same signer on the NSIS installer and installed channel executable. Those four names are the complete Windows signing-secret contract; the existing generic `CSC_*` secrets remain macOS-only and are stripped from the packaging environment. +- The service renews its certificate daily and expires it after 72 hours, so the approved publisher is pinned by certificate Subject and thumbprint pinning is unsupported. Every layer — `verify`, `prepare-release.yml`, `run-electron-builder.mjs`, `validate-win-artifacts.mjs`, and `apps/ade-cli/scripts/sign-windows-runtime.ps1` — fails while `WINDOWS_SIGNING_EXPECTED_THUMBPRINT` is set rather than ignoring it, so a pin cannot silently stop pinning. The pinned Subject is also the `publisherName` electron-builder writes into `app-update.yml`, which electron-updater parses as a Distinguished Name before running a downloaded installer. - Windows-only wrappers for the bundled `ade` CLI ship in `apps/desktop/scripts/`: `ade-cli-windows-wrapper.cmd` (channel-aware launcher), `ade-cli-install-path.cmd` (idempotent PATH install helper), and `windows-install-setup.ps1` (post-install CLI/service setup). The platform-agnostic `.sh` wrapper covers macOS/Linux. - The Windows installer is an assisted, per-user, non-elevating NSIS install. It bundles the prebuilt `cr-sqlite` native binary from `apps/desktop/vendor/crsqlite/win32-x64/`, a Windows node-pty ConPTY worker, and all Darwin/Linux remote-runtime sidecars. `validate-win-artifacts.mjs` asserts each one is unpacked and executes a real CRR change through the installed DLL. The custom install step repairs the channel-specific CLI shim, user PATH, and brain startup registration; uninstall removes only registry/PATH/protocol/startup state owned by that exact installation. - Electron-builder generates `resources/app-update.yml` from the GitHub publish configuration. The source default remains the upstream `arul28/ADE`; CI sets `ADE_RELEASE_REPOSITORY=${{ github.repository }}` so fork package validation proves the installed updater authority matches the repository that built it without changing upstream release behavior. -- Windows builds fresh on the release tag, the same shape as macOS. `build-win-release` mirrors `build-mac-release`: it builds, signs and validates on a `windows-latest` runner in-run, and `publish-release` attaches its output. `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` is the only Windows repository variable and the only maintainer-facing switch; with it off the Windows jobs skip cleanly and do not block the macOS release, and with it on a failed or skipped Windows build blocks the draft exactly as a failed macOS build does. Signing is fail-closed at every layer: the job-level secret requirement, `--require-signing` in `run-electron-builder.mjs`, and `--require-signed` Authenticode verification in `validate-win-artifacts.mjs` covering valid status, trusted RFC3161 timestamp, and pinned subject/thumbprint. `verify` re-checks that the signing secrets exist before any runner spins up, so a misconfigured gate fails in about a minute. +- Windows builds fresh on the release tag, the same shape as macOS. `build-win-release` mirrors `build-mac-release`: it builds, signs and validates on a `windows-latest` runner in-run, and `publish-release` attaches its output. `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` is the only Windows repository variable and the only maintainer-facing switch; with it off the Windows jobs skip cleanly and do not block the macOS release, and with it on a failed or skipped Windows build blocks the draft exactly as a failed macOS build does. Signing is fail-closed at every layer: the job-level secret requirement, `--require-signing` in `run-electron-builder.mjs`, and `--require-signed` Authenticode verification in `validate-win-artifacts.mjs` covering valid status, trusted RFC3161 timestamp, and the pinned publisher subject. `verify` re-checks that the signing secrets exist before any runner spins up, so a misconfigured gate fails in about a minute. - Publication no longer depends on promoting a previously approved proof run. That model, and the four repository variables that bound it, are gone. The accepted tradeoff is that a release may publish Windows bytes that never went through a manual clean-host proof sweep; the machine-checkable gates above all still apply, and the priority is that every desktop and CLI release also publishes Windows automatically. - `prepare-release.yml` is the platform-neutral non-publishing dry run and stays invokable in every flag state. Its `windows_proof` dispatch input is threaded through `release-core.yml` and turns on deliberate clean-host evidence collection: it builds and signs Windows even while the publication gate is off, and gates the proof staging, manifest generation, manifest validation, and bundle upload. Proof mode reads no repository variable, so evidence can be collected before Windows is enabled and again as a regression check afterwards. It emits `windows-proof-manifest.json` for the exact 40-character source SHA and hashes the installer, blockmap, updater manifest, standalone executable, native archive, `install.ps1`, `SHA256SUMS`, full-system scenario inventory, and #999 provenance index, and verifies that the checksum manifest binds the standalone files. The website download flag remains a separate disabled-by-default gate. See `docs/development/windows-release-proof.md`. - The `ade-win-proof-` evidence bundle is uploaded with an explicit 90-day `retention-days` so a lowered repository or organization default cannot expire it mid-review. It bounds how long the evidence stays downloadable, not when a release may be tagged. diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index e5b068c81..84eb282d0 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -69,7 +69,7 @@ Recent `main` work that is **not** inherently macOS-only but can surface path/sh Windows Graphics Capture/UI Automation is not implemented. - **iOS Simulator / Attention Notch** — hidden on Windows by capability. These remain macOS-only product surfaces. -- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. Release builds fail unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match the pinned subject or thumbprint; the installer and packaged app must also share one certificate. Windows builds fresh on the release tag behind the single `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` gate; disabled Windows publication does not block the macOS release path. SmartScreen reputation remains a release-engineering concern, not only app code. +- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. Release builds fail unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match the pinned publisher subject; the installer and packaged app must also share one certificate. Signing runs on Azure Artifact Signing, whose certificate rotates daily, so the pin is the certificate Subject and thumbprint pinning is refused outright. Windows builds fresh on the release tag behind the single `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` gate; disabled Windows publication does not block the macOS release path. SmartScreen reputation remains a release-engineering concern, not only app code. - **Windows as an SSH-bootstrap target** — local standalone install and `ade brain update` are implemented, and desktop remote bootstrap now detects and uploads native Windows targets alongside macOS/Linux. A Windows client can bootstrap those targets and reports actionable OpenSSH Client prerequisite diagnostics. HKCU startup-entry replacement is retry-safe but not transactional; Scheduled Tasks are legacy cleanup only. - **Docs in `AGENTS.md`** still emphasize macOS Codex/Computer Use; Windows developers should use this file + `docs/ARCHITECTURE.md` for WSL/VM dev notes if applicable. diff --git a/docs/playbooks/windows-signed-release.md b/docs/playbooks/windows-signed-release.md index 90f78c1a5..3bfb24319 100644 --- a/docs/playbooks/windows-signed-release.md +++ b/docs/playbooks/windows-signed-release.md @@ -52,28 +52,71 @@ automatically. ### 1. Configure signing -The current workflow supports a password-protected PFX/P12 signing certificate. If you choose a signing service that does not provide one, such as Microsoft Artifact Signing, adapt the workflow to that service before continuing. +Windows signing runs on [Azure Artifact Signing](https://learn.microsoft.com/en-us/azure/artifact-signing/overview), the service Microsoft previously called Trusted Signing. -Add these GitHub Actions secrets: +There is no certificate file to hold. Since June 2023 the CA/Browser Forum has required code-signing private keys to live on FIPS-validated hardware, so exportable `.pfx` delivery ended for OV and EV certificates alike, and this service never releases the certificate at all: [*"All certificates are securely stored within the service and are accessible only at the time of signing."*](https://learn.microsoft.com/en-us/azure/artifact-signing/faq) The build authenticates as a Microsoft Entra service principal and the service signs on its behalf. -- `WINDOWS_CSC_LINK`: the PFX/P12 file encoded as Base64 text, or a private HTTPS URL that returns it. -- `WINDOWS_CSC_KEY_PASSWORD`: the certificate password. -- `WINDOWS_SIGNING_EXPECTED_SUBJECT`: the certificate's complete Subject value exactly as Windows reports it, such as `CN=Publisher, O=Company, C=US`. -- `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`: the approved certificate fingerprint. +**Certificates rotate.** Azure Artifact Signing [renews the certificate daily and issues it with a 72-hour validity](https://learn.microsoft.com/en-us/azure/artifact-signing/concept-certificate-management). Two consequences run through this whole guide: -Set the complete Subject value, the fingerprint, or both. If both are set, both must match. +- The approved publisher is pinned by **certificate Subject**, never by thumbprint. A pinned thumbprint would stop matching within days. `WINDOWS_SIGNING_EXPECTED_THUMBPRINT` is not merely unused — every layer of the pipeline refuses to run while it is set, so the pin cannot quietly become meaningless. +- Every signature is RFC3161 timestamped against `http://timestamp.acs.microsoft.com`. Without that countersignature a shipped installer would stop verifying three days after it was built. + +#### The Azure resources + +These already exist. Recreate them only if the account is rebuilt. + +| | | +| --- | --- | +| Signing account | `arulsigning` | +| Resource group | `rg-signing` | +| Region | East US | +| Signing endpoint | `https://eus.codesigning.azure.net` | +| Certificate profile | `adePublicTrust` (Public Trust) | +| Certificate subject | `CN=Arul Sharma, O=Arul Sharma, L=Greensboro, S=nc, C=US` | + +The signing service principal is `ade-signing-ci`. It holds the **Artifact Signing Certificate Profile Signer** role scoped to the signing account; without that role signing fails with HTTP 403. + +The endpoint, account name, and certificate profile are not secrets. They are pinned in `run-electron-builder.mjs` and `sign-windows-runtime.ps1` so a maintainer running a signed build locally uses the same values CI does. `WINDOWS_SIGNING_ENDPOINT`, `WINDOWS_SIGNING_ACCOUNT_NAME`, and `WINDOWS_SIGNING_CERTIFICATE_PROFILE` override them for a fork or a migrated account. + +#### The GitHub Actions secrets + +Add these four: + +- `AZURE_TENANT_ID`: the Microsoft Entra tenant (directory) ID. +- `AZURE_CLIENT_ID`: the `ade-signing-ci` app registration's client (application) ID. +- `AZURE_CLIENT_SECRET`: a client secret generated for that app registration. +- `WINDOWS_SIGNING_EXPECTED_SUBJECT`: the certificate profile's complete Subject, exactly as Windows reports it — `CN=Arul Sharma, O=Arul Sharma, L=Greensboro, S=nc, C=US`. ```bash -gh secret set WINDOWS_CSC_LINK --repo arul28/ADE -gh secret set WINDOWS_CSC_KEY_PASSWORD --repo arul28/ADE +gh secret set AZURE_TENANT_ID --repo arul28/ADE +gh secret set AZURE_CLIENT_ID --repo arul28/ADE +gh secret set AZURE_CLIENT_SECRET --repo arul28/ADE gh secret set WINDOWS_SIGNING_EXPECTED_SUBJECT --repo arul28/ADE -gh secret set WINDOWS_SIGNING_EXPECTED_THUMBPRINT --repo arul28/ADE ``` Each command asks for the secret without printing it in the command. +The three `AZURE_*` names are exactly what Azure.Identity's `EnvironmentCredential` reads. On a GitHub-hosted runner that matters: `EnvironmentCredential` is first in the `DefaultAzureCredential` chain, so a complete service-principal triple is resolved before any managed-identity probe against an Azure instance-metadata endpoint the runner does not have. Microsoft's [FAQ](https://learn.microsoft.com/en-us/azure/artifact-signing/faq) gives the same guidance for non-Azure hosts and notes that when the chain does reach managed identity off-Azure it raises `Azure.Identity.CredentialUnavailableException`. An incomplete triple therefore fails slowly and confusingly rather than cleanly, which is why `verify` requires all three up front. + +If `WINDOWS_SIGNING_EXPECTED_SUBJECT` is missing, or if a leftover `WINDOWS_SIGNING_EXPECTED_THUMBPRINT` secret still exists, the run fails in `verify` about a minute in. Delete the thumbprint secret if it is still present: + +```bash +gh secret delete WINDOWS_SIGNING_EXPECTED_THUMBPRINT --repo arul28/ADE +``` + +`CSC_LINK` and `CSC_KEY_PASSWORD` are the macOS Developer ID secrets. Windows signing does not read them, and the packaging wrapper strips both from the electron-builder environment so they can never be picked up as a Windows signing identity. + Keep `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` unset or `0` until Step 5. +#### What actually signs + +`electron-builder` 26.8.1 has first-class support for this service through `win.azureSignOptions`. `run-electron-builder.mjs` supplies it on the `--require-signing` path only. That choice matters: electron-builder selects the Azure signing manager above the single chokepoint every Windows artifact passes through, so one configuration covers the packaged `ADE.exe` and its bundled DLLs, the NSIS installer, and the uninstaller. A separate post-build signing step — for example Microsoft's `Azure/artifact-signing-action` — runs after packaging, and could only sign the finished installer, leaving the `ADE.exe` already embedded inside it unsigned unless the installer were unpacked and rebuilt. + +Under the hood electron-builder 26 installs the `TrustedSigning` PowerShell module from PSGallery on the runner and calls `Invoke-TrustedSigning` per file. `sign-windows-runtime.ps1` signs the standalone `ade-win32-x64.exe` the same way, so both Windows artifacts share one signing path. Two things follow from that: + +- The runner needs outbound access to PSGallery and nuget.org on every signed build. There is no vendored copy. +- Microsoft's successor module is `ArtifactSigning` / `Invoke-ArtifactSigning`. electron-builder pins the older `TrustedSigning` module and only moves off it in v27, which replaces the module with `signtool /dlib`. Migrate the desktop build and the runtime signer together so the two Windows artifacts never diverge. + ### 2. Run the signed proof build in GitHub Actions `prepare-release.yml` is the platform-neutral non-publishing dry run. Pass @@ -229,8 +272,11 @@ Signing stays mandatory and machine-enforced whenever Windows builds. The `build-win-release` job requires the signing secrets before it packages anything, `run-electron-builder.mjs` runs with `--require-signing`, and `validate-win-artifacts.mjs` runs `--require-signed` Authenticode verification: -valid signature status, a trusted RFC3161 timestamp, and the pinned subject or -thumbprint. There is no unsigned Windows publication path. +valid signature status, a trusted RFC3161 timestamp, and the pinned publisher +Subject. It additionally requires that the installer and the `ADE.exe` it +installs carry the same certificate, which proves both came from one signing +operation rather than only sharing a Subject. There is no unsigned Windows +publication path. ### Required GitHub Actions settings @@ -243,10 +289,10 @@ name below is required unless marked optional. | Secret | Required when | Meaning | | --- | --- | --- | -| `WINDOWS_CSC_LINK` | Signed Windows builds | Base64 PFX/P12 or a private HTTPS URL returning it. Step 1. | -| `WINDOWS_CSC_KEY_PASSWORD` | Signed Windows builds | Certificate password. Step 1. | -| `WINDOWS_SIGNING_EXPECTED_SUBJECT` | Signed Windows builds, unless the thumbprint is set | Pinned certificate Subject. Step 1. | -| `WINDOWS_SIGNING_EXPECTED_THUMBPRINT` | Signed Windows builds, unless the subject is set | Pinned certificate fingerprint. Step 1. | +| `AZURE_TENANT_ID` | Signed Windows builds | Microsoft Entra tenant ID of the Azure Artifact Signing account. Step 1. | +| `AZURE_CLIENT_ID` | Signed Windows builds | Client ID of the `ade-signing-ci` service principal. Step 1. | +| `AZURE_CLIENT_SECRET` | Signed Windows builds | Client secret for that service principal. Step 1. | +| `WINDOWS_SIGNING_EXPECTED_SUBJECT` | Signed Windows builds | Pinned certificate Subject, and the `publisherName` the packaged updater verifies. Step 1. | | `CSC_LINK` | Every release | Existing macOS Developer ID certificate. Not used by Windows signing. | | `CSC_KEY_PASSWORD` | Every release | Existing macOS certificate password. | | `MACOS_DEVELOPER_ID_PROFILE_B64` | Every release | Existing macOS Developer ID provisioning profile. | @@ -256,7 +302,14 @@ name below is required unless marked optional. | `ADE_POSTHOG_PROJECT_TOKEN` | Optional | Analytics token baked into packaged builds. | | `ADE_POSTHOG_HOST` | Optional | Analytics host baked into packaged builds. | -`prepare-release.yml` reads the four `WINDOWS_*` signing secrets directly when +`WINDOWS_SIGNING_EXPECTED_THUMBPRINT` is not in this table because it is not +supported. Azure Artifact Signing renews the certificate daily and expires it +after 72 hours, so a thumbprint pin would fail every release within days. +`verify`, `prepare-release.yml`, `run-electron-builder.mjs`, +`validate-win-artifacts.mjs`, and `sign-windows-runtime.ps1` each fail while that +secret exists, rather than ignoring it and leaving the release apparently pinned. + +`prepare-release.yml` reads the four Windows signing secrets directly when `windows_proof` is set, and passes everything else through with `secrets: inherit`. @@ -275,11 +328,12 @@ Confirm: - The normal `ci-pass` check succeeded for that exact commit. - The version tag does not already exist. - `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1`. -- The four Windows signing secrets are still present. With the gate on and a - secret missing, the tagged run stops about a minute in, in `verify`, with - `Windows releases require the WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD - secrets.` or `Windows releases require the WINDOWS_SIGNING_EXPECTED_SUBJECT or - WINDOWS_SIGNING_EXPECTED_THUMBPRINT secret to pin the approved publisher.` See +- The four Windows signing secrets are still present, and the + `ade-signing-ci` client secret has not expired. With the gate on and a secret + missing, the tagged run stops about a minute in, in `verify`, with + `Windows releases require the AZURE_TENANT_ID, AZURE_CLIENT_ID and + AZURE_CLIENT_SECRET secrets.` or `Windows releases require the + WINDOWS_SIGNING_EXPECTED_SUBJECT secret to pin the approved publisher.` See [Required GitHub Actions settings](#required-github-actions-settings). Confirm the current value before tagging: @@ -412,7 +466,22 @@ public signed release as an ongoing regression check. - Problem found after publication: hide the website link, set `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0`, fix and test a higher version, then set it back to `1` before tagging that version. -- Signing certificate changes: update the signing secrets and repeat the signed installer tests. +- Signing certificate changes: the certificate rotates on its own and needs no + action. Update `WINDOWS_SIGNING_EXPECTED_SUBJECT` and repeat the signed + installer tests only if the certificate profile's Subject itself changes. +- `Windows installer and packaged executable were signed by different + certificates`: the build straddled the service's daily certificate rotation. + Nothing is wrong with the configuration. Rerun the build. +- Signing fails with HTTP 403: the `ade-signing-ci` service principal has lost + the **Artifact Signing Certificate Profile Signer** role on the `arulsigning` + account, or its client secret expired. +- Signing fails with `CredentialUnavailableException` or hangs before signing: + one of `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` is missing + or wrong, so the credential chain fell past `EnvironmentCredential` and tried + to reach an Azure instance-metadata endpoint the runner does not have. +- The build fails installing the `TrustedSigning` module: the runner could not + reach PSGallery or nuget.org. The signing toolchain is downloaded per build + and is not vendored. - Release workflow is rerun after publication: the workflow stops instead of replacing public files. Publish a higher version for any correction. Never put a certificate, private key, password, private certificate URL, or access token in the repository, logs, release notes, or test record. @@ -428,6 +497,7 @@ WinGet, the Microsoft Store, MSIX, and enterprise deployment can be added later. - [Package scripts](../../apps/desktop/package.json) - [Windows packaging script](../../apps/desktop/scripts/run-electron-builder.mjs) - [Windows release-file checker](../../apps/desktop/scripts/validate-win-artifacts.mjs) +- [Standalone Windows runtime signer](../../apps/ade-cli/scripts/sign-windows-runtime.ps1) - [Windows uninstall cleanup](../../apps/desktop/scripts/windows-uninstall-cleanup.ps1) - [Non-publishing workflow](../../.github/workflows/prepare-release.yml) - [Tag-triggered release workflow](../../.github/workflows/release.yml) @@ -440,8 +510,13 @@ WinGet, the Microsoft Store, MSIX, and enterprise deployment can be added later. ## References +- [Azure Artifact Signing overview](https://learn.microsoft.com/en-us/azure/artifact-signing/overview) +- [Azure Artifact Signing certificate management](https://learn.microsoft.com/en-us/azure/artifact-signing/concept-certificate-management) +- [Azure Artifact Signing signing integrations](https://learn.microsoft.com/en-us/azure/artifact-signing/how-to-signing-integrations) +- [Azure Artifact Signing role assignment](https://learn.microsoft.com/en-us/azure/artifact-signing/tutorial-assign-roles) +- [Azure Artifact Signing FAQ](https://learn.microsoft.com/en-us/azure/artifact-signing/faq) +- [Azure Identity credential chains](https://learn.microsoft.com/en-us/dotnet/azure/sdk/authentication/credential-chains) - [electron-builder Windows signing](https://www.electron.build/docs/features/code-signing/code-signing-win/) - [GitHub Actions secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets) - [Microsoft code-signing options](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/code-signing-options) - [Microsoft SmartScreen reputation](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/smartscreen-reputation) -- [Microsoft Artifact Signing](https://learn.microsoft.com/en-us/azure/artifact-signing/overview) From 561e60764d3ec17a0f37faf6ec0d253ad432c052 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 13:22:41 -0400 Subject: [PATCH 21/27] fix(release): move publishing into its own reusable workflow GitHub refused to start prepare-release.yml outright: Invalid workflow file: .github/workflows/prepare-release.yml#L112 The nested job 'publish-release' is requesting 'contents: write', but is only allowed 'contents: read'. release-core.yml is called by two entry points with different intents. release.yml is the real release and grants contents: write. prepare-release.yml is the non-publishing validation run and grants contents: read deliberately. GitHub validates a called workflow's job permissions statically, at parse time, before any job-level `if:` is evaluated, so publish-release's `permissions: contents: write` broke the dry run even though its `if: always() && inputs.publish && ...` meant it could never run there. Granting contents: write to prepare-release.yml would have fixed the parse error and destroyed the only thing that actually guarantees a dry run cannot create a release. Its header comment promises "It never creates or updates a GitHub Release"; the read-only token is what enforces that promise, not the hardcoded publish input. So the capability moves instead of the permission. publish-release now lives in release-publish.yml, a reusable workflow carrying its own contents: write, called only by release.yml. Nothing in release-core.yml requests more than contents: read any more, and the publish input is gone with it, so a dry run cannot reach the publishing path at all rather than being trusted to pass publish: false. Do not merge publish-release back into release-core.yml. That would reintroduce this exact failure the next time anyone dispatches the dry run. The publish gate is reconstructed, not relaxed. release-core.yml gains a build-results job with `if: always()` that republishes the three build results the gate reads as workflow outputs, and release.yml's publish-release job applies the same condition it always had: with the Windows gate on, a failed or skipped Windows build blocks the draft exactly as a failed macOS build does, and always() still lets the gate evaluate when build-win-release is legitimately skipped with the gate off. Artifacts are scoped to the run, not the workflow file, so the publish job still downloads ade-mac-release-*, ade-win-release-* and ade-runtime-* by name from the same run. Based-on: nsxdavid/ADE#999 --- .agents/skills/release/SKILL.md | 5 +- .github/workflows/prepare-release.yml | 1 - .github/workflows/release-core.yml | 374 ++---------------- .github/workflows/release-publish.yml | 354 +++++++++++++++++ .github/workflows/release.yml | 31 +- .../scripts/windows-release-contract.test.mjs | 58 ++- docs/playbooks/windows-signed-release.md | 3 +- 7 files changed, 476 insertions(+), 350 deletions(-) create mode 100644 .github/workflows/release-publish.yml diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index dbf37d460..8b16e7372 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -142,6 +142,7 @@ relevant preflights pass. ```bash test -f .github/workflows/release.yml test -f .github/workflows/release-core.yml + test -f .github/workflows/release-publish.yml gh workflow view release.yml --repo arul28/ADE ``` @@ -373,7 +374,9 @@ Expected shape: - `build-win-release` builds/signs/validates Windows independently, in parallel with the mac jobs, when `platforms` includes `win`. With the gate off it is skipped, and a skipped Windows job does not block the mac release. -- `publish-release` merges the per-arch updater manifests and creates the draft +- `publish-release` (in `release-publish.yml`, called by `release.yml` after + `run-release` succeeds) merges the per-arch updater manifests and creates the + draft - `update-brew-tap` runs after publication If `platforms=mac,win` and `build-win-release` did not run, stop. The gate and diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index eba27d6c8..fec62471e 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -115,6 +115,5 @@ jobs: with: release_tag: ${{ needs.resolve.outputs.tag_name }} target_ref: ${{ needs.resolve.outputs.target_sha }} - publish: false windows_proof: ${{ inputs.windows_proof }} secrets: inherit diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 7d6181452..50e2d211c 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -11,11 +11,6 @@ on: description: Commit SHA or ref to build and release. required: true type: string - publish: - description: Whether to upload validated artifacts to a draft GitHub release. - required: false - default: false - type: boolean windows_proof: description: >- Collect clean-host Windows proof evidence. Builds Windows even while @@ -25,7 +20,24 @@ on: required: false default: false type: boolean - + outputs: + runtime_result: + description: Aggregate result of the build-runtime-binaries matrix. + value: ${{ jobs.build-results.outputs.runtime_result }} + mac_result: + description: Aggregate result of the build-mac-release matrix. + value: ${{ jobs.build-results.outputs.mac_result }} + windows_result: + description: >- + Result of build-win-release. 'skipped' when the Windows publication + gate is off and no proof run was requested. + value: ${{ jobs.build-results.outputs.windows_result }} + +# No job in this workflow may request more than these permissions. It is called +# by prepare-release.yml with a read-only token, and GitHub rejects a called +# workflow at parse time if any nested job requests more than the caller grants, +# regardless of whether that job would ever run. Publishing lives in +# release-publish.yml for exactly this reason. permissions: actions: read checks: read @@ -141,7 +153,8 @@ jobs: # One job per arch, in parallel on native runners (arm64 -> macos-15, # x64 -> macos-15-intel). Wall-clock ~= one arch's build+notarize instead of # both serialized. Each job signs+notarizes+staples its own arch; the - # per-arch updater manifests are merged in publish-release. + # per-arch updater manifests are merged by the publish-release job in + # release-publish.yml. strategy: fail-fast: false matrix: @@ -310,7 +323,7 @@ jobs: build-win-release: # Same shape as build-mac-release: the tagged commit is built, signed and - # validated in-run, and its output is published by publish-release. The + # validated in-run, and its output is published by release-publish.yml. The # windows_proof input additionally builds Windows while the publication gate # is still off, so clean-host evidence can be collected deliberately. if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' || inputs.windows_proof }} @@ -695,336 +708,31 @@ jobs: if-no-files-found: error compression-level: 0 - publish-release: - # Windows is a first-class release platform: when its gate is on, a failed - # or skipped Windows build blocks the draft exactly as a failed macOS build - # does. always() is still needed so the job evaluates when build-win-release - # is legitimately skipped with the gate off. - if: >- - ${{ - always() - && inputs.publish - && needs.build-runtime-binaries.result == 'success' - && needs.build-mac-release.result == 'success' - && ( - vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1' - || needs.build-win-release.result == 'success' - ) - }} + build-results: + # This workflow deliberately holds no job that can write to the repository, + # so the publish gate cannot be evaluated here (see release-publish.yml). + # Republish the three build results the gate reads as workflow outputs. + # always() so a failed or skipped build is still reported rather than + # collapsing the outputs to empty, which is what lets the caller keep the + # original semantics: with the Windows gate on, a failed or skipped Windows + # build blocks the draft exactly as a failed macOS build does. + if: always() needs: - build-runtime-binaries - build-mac-release - build-win-release - permissions: - actions: read - contents: write runs-on: ubuntu-latest + outputs: + runtime_result: ${{ needs.build-runtime-binaries.result }} + mac_result: ${{ needs.build-mac-release.result }} + windows_result: ${{ needs.build-win-release.result }} steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.target_ref }} - fetch-depth: 1 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Download per-arch macOS release artifacts - uses: actions/download-artifact@v4 - with: - pattern: ade-mac-release-${{ inputs.release_tag }}-* - path: release-assets/mac - merge-multiple: true - - - name: Merge per-arch updater manifests into latest-mac.yml - run: | - set -euo pipefail - # Each per-arch build uploaded latest-mac-.yml; merge them into a - # single latest-mac.yml whose files[] lists both arch zips (electron-updater - # picks the right one by arch). --ignore-scripts skips the heavy electron - # postinstall — we only need the pure-JS `yaml` dep for the merge. - (cd apps/desktop && npm ci --ignore-scripts) - node apps/desktop/scripts/merge-mac-latest-yml.mjs \ - release-assets/mac/latest-mac-arm64.yml \ - release-assets/mac/latest-mac-x64.yml \ - release-assets/mac/latest-mac.yml - rm -f release-assets/mac/latest-mac-arm64.yml release-assets/mac/latest-mac-x64.yml - - - name: Download Windows release artifacts - if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} - uses: actions/download-artifact@v4 - with: - name: ade-win-release-${{ inputs.release_tag }} - path: release-assets/win - - - name: Download ADE runtime binaries - uses: actions/download-artifact@v4 - with: - pattern: ade-runtime-* - path: release-assets/runtime - merge-multiple: true - - - name: Add standalone runtime installer - run: | - cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh - chmod 755 release-assets/runtime/install.sh - - # The published SHA256SUMS is generated later, over the exact merged set of - # standalone assets this release uploads. Hashing release-assets/runtime on - # its own would describe a set that is never published once the Windows - # gate contributes the signed win32 standalone files. - - name: Validate publish asset manifest - run: | - set -euo pipefail - shopt -s nullglob - - require_file() { - local file="$1" - local label="${2:-$1}" - if [ ! -s "$file" ]; then - echo "::error::Missing or empty $label: $file" - exit 1 - fi - } - - require_glob() { - local pattern="$1" - local label="${2:-$1}" - mapfile -t matches < <(compgen -G "$pattern" || true) - if [ "${#matches[@]}" -eq 0 ]; then - echo "::error::Missing $label matching $pattern" - exit 1 - fi - for file in "${matches[@]}"; do - require_file "$file" "$label" - done - } - - require_glob 'release-assets/mac/*.dmg' 'macOS DMG' - require_glob 'release-assets/mac/*.zip' 'macOS zip' - require_file 'release-assets/mac/latest-mac.yml' 'macOS auto-update metadata' - - require_file 'release-assets/runtime/install.sh' 'standalone runtime installer' - if [ ! -x 'release-assets/runtime/install.sh' ]; then - echo "::error::Standalone runtime installer is not executable." - exit 1 - fi - - # Cross-platform runtime allowlist. Every ade-* file in the runtime - # download must be one of the ten this release is allowed to carry, in - # every flag state, so an extra or missing sidecar can never reach the - # draft. All ten are produced by build-runtime-binaries in this run. - runtime_files=( - ade-darwin-arm64 - ade-darwin-arm64.native.tar.gz - ade-darwin-x64 - ade-darwin-x64.native.tar.gz - ade-linux-arm64 - ade-linux-arm64.native.tar.gz - ade-linux-x64 - ade-linux-x64.native.tar.gz - ade-win32-x64.exe - ade-win32-x64.native.tar.gz - ) - mapfile -t actual_runtime_files < <( - find release-assets/runtime -maxdepth 1 -type f -name 'ade-*' -printf '%f\n' | LC_ALL=C sort - ) - mapfile -t expected_runtime_files < <(printf '%s\n' "${runtime_files[@]}" | LC_ALL=C sort) - if ! diff -u <(printf '%s\n' "${expected_runtime_files[@]}") <(printf '%s\n' "${actual_runtime_files[@]}"); then - echo "::error::Runtime artifacts contain an unauthorized or missing entry." - exit 1 - fi - - for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do - binary="release-assets/runtime/ade-$target" - require_file "$binary" "ADE runtime binary for $target" - require_file "release-assets/runtime/ade-$target.native.tar.gz" "ADE native dependency archive for $target" - archive_listing="$RUNNER_TEMP/ade-$target-native-files.txt" - tar -tzf "release-assets/runtime/ade-$target.native.tar.gz" > "$archive_listing" - grep -q '^\./node_modules/' "$archive_listing" || { - echo "::error::ADE native dependency archive for $target is missing node_modules." - exit 1 - } - done - - - name: Validate gated Windows publish asset manifest - if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} - run: | - set -euo pipefail - shopt -s nullglob - - # Desktop installer set, built and signed by build-win-release in this - # same run. - installers=(release-assets/win/ADE-*-win-x64.exe) - blockmaps=(release-assets/win/ADE-*-win-x64.exe.blockmap) - if [ "${#installers[@]}" -ne 1 ] || [ "${#blockmaps[@]}" -ne 1 ]; then - echo "::error::Expected exactly one signed Windows installer and blockmap." - exit 1 - fi - test -s "${installers[0]}" - test -s "${blockmaps[0]}" - test -s release-assets/win/latest.yml - - # Updater feed correctness: latest.yml must name the installer that is - # actually being published beside it, the same contract the merged - # latest-mac.yml carries for macOS. - installer_name="$(basename "${installers[0]}")" - if ! grep -Fq "$installer_name" release-assets/win/latest.yml; then - echo "::error::release-assets/win/latest.yml does not reference the published installer $installer_name." - exit 1 - fi - - # Standalone Windows runtime, built and signed by build-runtime-binaries - # in this same run. - test -s release-assets/runtime/install.ps1 - test -s release-assets/runtime/ade-win32-x64.exe - test -s release-assets/runtime/ade-win32-x64.native.tar.gz - # Cross-check the Windows standalone bytes against the digests computed - # on the signing runner, so a corrupted artifact transfer cannot reach - # the draft. That manifest is written by Git Bash on windows-latest, - # where sha256sum reads in binary mode and marks each name with a - # leading '*'; normalize it before parsing. Text and binary mode are - # identical here on Linux, so the normalized form verifies correctly. - test -s release-assets/runtime/SHA256SUMS - windows_sums="$RUNNER_TEMP/windows-standalone-SHA256SUMS" - sed 's/^\([0-9a-f]\{64\}\) [ *]/\1 /' release-assets/runtime/SHA256SUMS > "$windows_sums" - if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' "$windows_sums"; then - echo "::error::Windows standalone checksum manifest has an invalid entry." - exit 1 - fi - mapfile -t actual_checksum_files < <(awk '{ print $2 }' "$windows_sums" | LC_ALL=C sort) - mapfile -t expected_checksum_files < <( - printf '%s\n' install.ps1 ade-win32-x64.exe ade-win32-x64.native.tar.gz | LC_ALL=C sort - ) - if ! diff -u <(printf '%s\n' "${expected_checksum_files[@]}") <(printf '%s\n' "${actual_checksum_files[@]}"); then - echo "::error::Windows standalone checksum manifest does not name the exact authorized Windows runtime set." - exit 1 - fi - (cd release-assets/runtime && sha256sum -c "$windows_sums") - - - name: Create or update draft GitHub release + - name: Report build results env: - GH_TOKEN: ${{ github.token }} - TAG_NAME: ${{ inputs.release_tag }} - TARGET_REF: ${{ inputs.target_ref }} - GH_REPO: ${{ github.repository }} - PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + RUNTIME_RESULT: ${{ needs.build-runtime-binaries.result }} + MAC_RESULT: ${{ needs.build-mac-release.result }} + WINDOWS_RESULT: ${{ needs.build-win-release.result }} run: | - set -euo pipefail - shopt -s nullglob - # Every asset comes from this run. The per-arch macOS zips + - # latest-mac.yml are what electron-updater consumes; DMGs are the human - # downloads. Mac blockmaps stay omitted. The Windows installer, - # blockmap and latest.yml are the electron-updater equivalents. - base_files=( - release-assets/mac/*.dmg - release-assets/mac/*.zip - release-assets/mac/latest-mac.yml - ) - # Cross-platform standalone runtime. These are notarized or signed by - # the build-runtime-binaries jobs of this same run, which is what keeps - # them byte-identical to the copies bundled inside the desktop - # installers published beside them. - runtime_files=( - release-assets/runtime/install.sh - release-assets/runtime/ade-darwin-* - release-assets/runtime/ade-linux-* - ) - files=("${base_files[@]}" "${runtime_files[@]}") - checksum_files=("${runtime_files[@]}") - - # One switch decides whether this release carries Windows at all. - if [ "$PUBLISH_WINDOWS" = "1" ]; then - windows_files=( - release-assets/win/ADE-*-win-x64.exe - release-assets/win/ADE-*-win-x64.exe.blockmap - release-assets/win/latest.yml - release-assets/runtime/install.ps1 - release-assets/runtime/ade-win32-x64.exe - release-assets/runtime/ade-win32-x64.native.tar.gz - ) - files=("${files[@]}" "${windows_files[@]}") - checksum_files+=( - release-assets/runtime/install.ps1 - release-assets/runtime/ade-win32-x64.exe - release-assets/runtime/ade-win32-x64.native.tar.gz - ) - fi - - # One checksum manifest over exactly the standalone assets uploaded - # below, regenerated so the digests always describe the bytes actually - # published rather than any per-job manifest staged upstream. - # install-runtime.sh and install-runtime.ps1 resolve their own - # platform's entries from this file. - mkdir -p release-assets/publish - published_checksums=release-assets/publish/SHA256SUMS - for checksum_file in "${checksum_files[@]}"; do - printf '%s %s\n' \ - "$(sha256sum "$checksum_file" | cut -d ' ' -f 1)" \ - "$(basename "$checksum_file")" - done | LC_ALL=C sort -k2 > "$published_checksums" - if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' "$published_checksums"; then - echo "::error::Published checksum manifest has an invalid entry." - exit 1 - fi - mapfile -t expected_published_checksums < <( - for checksum_file in "${checksum_files[@]}"; do basename "$checksum_file"; done | LC_ALL=C sort - ) - mapfile -t actual_published_checksums < <(awk '{ print $2 }' "$published_checksums") - if ! diff -u \ - <(printf '%s\n' "${expected_published_checksums[@]}") \ - <(printf '%s\n' "${actual_published_checksums[@]}"); then - echo "::error::Published checksum manifest does not name the exact published standalone asset set." - exit 1 - fi - files+=("$published_checksums") - - if [ "${#files[@]}" -eq 0 ]; then - echo "::error::No release artifacts were found after validation." - exit 1 - fi - - if gh release view "$TAG_NAME" --repo "$GH_REPO" >/dev/null 2>&1; then - is_draft="$(gh release view "$TAG_NAME" --repo "$GH_REPO" --json isDraft --jq '.isDraft')" - if [ "$is_draft" != "true" ]; then - echo "::error::Release $TAG_NAME is already public. Refusing to overwrite published assets." - exit 1 - fi - approved_target="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" - release_tag_target="$(gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq '.sha' | tr '[:upper:]' '[:lower:]')" - if [ "$release_tag_target" != "$approved_target" ]; then - echo "::error::Existing draft tag $TAG_NAME resolves to $release_tag_target, not approved target $approved_target." - exit 1 - fi - mapfile -t existing_assets < <( - gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' - ) - for asset in "${existing_assets[@]}"; do - gh release delete-asset "$TAG_NAME" "$asset" --repo "$GH_REPO" --yes - done - gh release upload "$TAG_NAME" "${files[@]}" --repo "$GH_REPO" --clobber - else - gh release create "$TAG_NAME" "${files[@]}" \ - --repo "$GH_REPO" \ - --draft \ - --title "$TAG_NAME" \ - --generate-notes \ - --target "$TARGET_REF" - fi - - approved_target="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" - final_tag_target="$(gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq '.sha' | tr '[:upper:]' '[:lower:]')" - if [ "$final_tag_target" != "$approved_target" ]; then - echo "::error::Draft release tag $TAG_NAME resolves to $final_tag_target, not approved target $approved_target." - exit 1 - fi - mapfile -t expected_assets < <( - for file in "${files[@]}"; do basename "$file"; done | LC_ALL=C sort - ) - mapfile -t actual_assets < <( - gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' | LC_ALL=C sort - ) - if ! diff -u <(printf '%s\n' "${expected_assets[@]}") <(printf '%s\n' "${actual_assets[@]}"); then - echo "::error::Draft release asset inventory differs from the exact validated set." - exit 1 - fi + echo "build-runtime-binaries: $RUNTIME_RESULT" + echo "build-mac-release: $MAC_RESULT" + echo "build-win-release: $WINDOWS_RESULT" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 000000000..c74985239 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,354 @@ +name: Release publish + +# The only job in the release pipeline that can write to the repository, kept +# deliberately in its own reusable workflow. +# +# release-core.yml is called by two entry points with different intents: +# release.yml, the real release, which grants `contents: write`, and +# prepare-release.yml, the non-publishing validation run, which grants +# `contents: read` on purpose so a dry run provably cannot create a release. +# GitHub validates a called workflow's job permissions statically, at parse +# time, before any job-level `if:` is evaluated, so a job requesting +# `contents: write` anywhere inside release-core.yml makes prepare-release.yml +# fail to load outright ("The nested job 'publish-release' is requesting +# 'contents: write', but is only allowed 'contents: read'"). +# +# Granting write to prepare-release.yml would have fixed the parse error and +# destroyed the property that workflow exists to guarantee. Splitting the +# write-capable job out instead keeps the dry run's token read-only, which is +# the actual enforcement. Do not merge this job back into release-core.yml. +# +# The publish gate that used to live on this job's `if:` now lives in +# release.yml, reconstructed from release-core.yml's build-result outputs. + +on: + workflow_call: + inputs: + release_tag: + description: Release tag to publish. + required: true + type: string + target_ref: + description: Commit SHA or ref the release must resolve to. + required: true + type: string + +permissions: + actions: read + contents: write + +jobs: + publish-release: + permissions: + actions: read + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 1 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Download per-arch macOS release artifacts + uses: actions/download-artifact@v4 + with: + pattern: ade-mac-release-${{ inputs.release_tag }}-* + path: release-assets/mac + merge-multiple: true + + - name: Merge per-arch updater manifests into latest-mac.yml + run: | + set -euo pipefail + # Each per-arch build uploaded latest-mac-.yml; merge them into a + # single latest-mac.yml whose files[] lists both arch zips (electron-updater + # picks the right one by arch). --ignore-scripts skips the heavy electron + # postinstall — we only need the pure-JS `yaml` dep for the merge. + (cd apps/desktop && npm ci --ignore-scripts) + node apps/desktop/scripts/merge-mac-latest-yml.mjs \ + release-assets/mac/latest-mac-arm64.yml \ + release-assets/mac/latest-mac-x64.yml \ + release-assets/mac/latest-mac.yml + rm -f release-assets/mac/latest-mac-arm64.yml release-assets/mac/latest-mac-x64.yml + + - name: Download Windows release artifacts + if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + uses: actions/download-artifact@v4 + with: + name: ade-win-release-${{ inputs.release_tag }} + path: release-assets/win + + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: release-assets/runtime + merge-multiple: true + + - name: Add standalone runtime installer + run: | + cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh + chmod 755 release-assets/runtime/install.sh + + # The published SHA256SUMS is generated later, over the exact merged set of + # standalone assets this release uploads. Hashing release-assets/runtime on + # its own would describe a set that is never published once the Windows + # gate contributes the signed win32 standalone files. + - name: Validate publish asset manifest + run: | + set -euo pipefail + shopt -s nullglob + + require_file() { + local file="$1" + local label="${2:-$1}" + if [ ! -s "$file" ]; then + echo "::error::Missing or empty $label: $file" + exit 1 + fi + } + + require_glob() { + local pattern="$1" + local label="${2:-$1}" + mapfile -t matches < <(compgen -G "$pattern" || true) + if [ "${#matches[@]}" -eq 0 ]; then + echo "::error::Missing $label matching $pattern" + exit 1 + fi + for file in "${matches[@]}"; do + require_file "$file" "$label" + done + } + + require_glob 'release-assets/mac/*.dmg' 'macOS DMG' + require_glob 'release-assets/mac/*.zip' 'macOS zip' + require_file 'release-assets/mac/latest-mac.yml' 'macOS auto-update metadata' + + require_file 'release-assets/runtime/install.sh' 'standalone runtime installer' + if [ ! -x 'release-assets/runtime/install.sh' ]; then + echo "::error::Standalone runtime installer is not executable." + exit 1 + fi + + # Cross-platform runtime allowlist. Every ade-* file in the runtime + # download must be one of the ten this release is allowed to carry, in + # every flag state, so an extra or missing sidecar can never reach the + # draft. All ten are produced by build-runtime-binaries in this run. + runtime_files=( + ade-darwin-arm64 + ade-darwin-arm64.native.tar.gz + ade-darwin-x64 + ade-darwin-x64.native.tar.gz + ade-linux-arm64 + ade-linux-arm64.native.tar.gz + ade-linux-x64 + ade-linux-x64.native.tar.gz + ade-win32-x64.exe + ade-win32-x64.native.tar.gz + ) + mapfile -t actual_runtime_files < <( + find release-assets/runtime -maxdepth 1 -type f -name 'ade-*' -printf '%f\n' | LC_ALL=C sort + ) + mapfile -t expected_runtime_files < <(printf '%s\n' "${runtime_files[@]}" | LC_ALL=C sort) + if ! diff -u <(printf '%s\n' "${expected_runtime_files[@]}") <(printf '%s\n' "${actual_runtime_files[@]}"); then + echo "::error::Runtime artifacts contain an unauthorized or missing entry." + exit 1 + fi + + for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do + binary="release-assets/runtime/ade-$target" + require_file "$binary" "ADE runtime binary for $target" + require_file "release-assets/runtime/ade-$target.native.tar.gz" "ADE native dependency archive for $target" + archive_listing="$RUNNER_TEMP/ade-$target-native-files.txt" + tar -tzf "release-assets/runtime/ade-$target.native.tar.gz" > "$archive_listing" + grep -q '^\./node_modules/' "$archive_listing" || { + echo "::error::ADE native dependency archive for $target is missing node_modules." + exit 1 + } + done + + - name: Validate gated Windows publish asset manifest + if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + run: | + set -euo pipefail + shopt -s nullglob + + # Desktop installer set, built and signed by build-win-release in this + # same run. + installers=(release-assets/win/ADE-*-win-x64.exe) + blockmaps=(release-assets/win/ADE-*-win-x64.exe.blockmap) + if [ "${#installers[@]}" -ne 1 ] || [ "${#blockmaps[@]}" -ne 1 ]; then + echo "::error::Expected exactly one signed Windows installer and blockmap." + exit 1 + fi + test -s "${installers[0]}" + test -s "${blockmaps[0]}" + test -s release-assets/win/latest.yml + + # Updater feed correctness: latest.yml must name the installer that is + # actually being published beside it, the same contract the merged + # latest-mac.yml carries for macOS. + installer_name="$(basename "${installers[0]}")" + if ! grep -Fq "$installer_name" release-assets/win/latest.yml; then + echo "::error::release-assets/win/latest.yml does not reference the published installer $installer_name." + exit 1 + fi + + # Standalone Windows runtime, built and signed by build-runtime-binaries + # in this same run. + test -s release-assets/runtime/install.ps1 + test -s release-assets/runtime/ade-win32-x64.exe + test -s release-assets/runtime/ade-win32-x64.native.tar.gz + # Cross-check the Windows standalone bytes against the digests computed + # on the signing runner, so a corrupted artifact transfer cannot reach + # the draft. That manifest is written by Git Bash on windows-latest, + # where sha256sum reads in binary mode and marks each name with a + # leading '*'; normalize it before parsing. Text and binary mode are + # identical here on Linux, so the normalized form verifies correctly. + test -s release-assets/runtime/SHA256SUMS + windows_sums="$RUNNER_TEMP/windows-standalone-SHA256SUMS" + sed 's/^\([0-9a-f]\{64\}\) [ *]/\1 /' release-assets/runtime/SHA256SUMS > "$windows_sums" + if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' "$windows_sums"; then + echo "::error::Windows standalone checksum manifest has an invalid entry." + exit 1 + fi + mapfile -t actual_checksum_files < <(awk '{ print $2 }' "$windows_sums" | LC_ALL=C sort) + mapfile -t expected_checksum_files < <( + printf '%s\n' install.ps1 ade-win32-x64.exe ade-win32-x64.native.tar.gz | LC_ALL=C sort + ) + if ! diff -u <(printf '%s\n' "${expected_checksum_files[@]}") <(printf '%s\n' "${actual_checksum_files[@]}"); then + echo "::error::Windows standalone checksum manifest does not name the exact authorized Windows runtime set." + exit 1 + fi + (cd release-assets/runtime && sha256sum -c "$windows_sums") + + - name: Create or update draft GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ inputs.release_tag }} + TARGET_REF: ${{ inputs.target_ref }} + GH_REPO: ${{ github.repository }} + PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + run: | + set -euo pipefail + shopt -s nullglob + # Every asset comes from this run. The per-arch macOS zips + + # latest-mac.yml are what electron-updater consumes; DMGs are the human + # downloads. Mac blockmaps stay omitted. The Windows installer, + # blockmap and latest.yml are the electron-updater equivalents. + base_files=( + release-assets/mac/*.dmg + release-assets/mac/*.zip + release-assets/mac/latest-mac.yml + ) + # Cross-platform standalone runtime. These are notarized or signed by + # the build-runtime-binaries jobs of this same run, which is what keeps + # them byte-identical to the copies bundled inside the desktop + # installers published beside them. + runtime_files=( + release-assets/runtime/install.sh + release-assets/runtime/ade-darwin-* + release-assets/runtime/ade-linux-* + ) + files=("${base_files[@]}" "${runtime_files[@]}") + checksum_files=("${runtime_files[@]}") + + # One switch decides whether this release carries Windows at all. + if [ "$PUBLISH_WINDOWS" = "1" ]; then + windows_files=( + release-assets/win/ADE-*-win-x64.exe + release-assets/win/ADE-*-win-x64.exe.blockmap + release-assets/win/latest.yml + release-assets/runtime/install.ps1 + release-assets/runtime/ade-win32-x64.exe + release-assets/runtime/ade-win32-x64.native.tar.gz + ) + files=("${files[@]}" "${windows_files[@]}") + checksum_files+=( + release-assets/runtime/install.ps1 + release-assets/runtime/ade-win32-x64.exe + release-assets/runtime/ade-win32-x64.native.tar.gz + ) + fi + + # One checksum manifest over exactly the standalone assets uploaded + # below, regenerated so the digests always describe the bytes actually + # published rather than any per-job manifest staged upstream. + # install-runtime.sh and install-runtime.ps1 resolve their own + # platform's entries from this file. + mkdir -p release-assets/publish + published_checksums=release-assets/publish/SHA256SUMS + for checksum_file in "${checksum_files[@]}"; do + printf '%s %s\n' \ + "$(sha256sum "$checksum_file" | cut -d ' ' -f 1)" \ + "$(basename "$checksum_file")" + done | LC_ALL=C sort -k2 > "$published_checksums" + if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' "$published_checksums"; then + echo "::error::Published checksum manifest has an invalid entry." + exit 1 + fi + mapfile -t expected_published_checksums < <( + for checksum_file in "${checksum_files[@]}"; do basename "$checksum_file"; done | LC_ALL=C sort + ) + mapfile -t actual_published_checksums < <(awk '{ print $2 }' "$published_checksums") + if ! diff -u \ + <(printf '%s\n' "${expected_published_checksums[@]}") \ + <(printf '%s\n' "${actual_published_checksums[@]}"); then + echo "::error::Published checksum manifest does not name the exact published standalone asset set." + exit 1 + fi + files+=("$published_checksums") + + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::No release artifacts were found after validation." + exit 1 + fi + + if gh release view "$TAG_NAME" --repo "$GH_REPO" >/dev/null 2>&1; then + is_draft="$(gh release view "$TAG_NAME" --repo "$GH_REPO" --json isDraft --jq '.isDraft')" + if [ "$is_draft" != "true" ]; then + echo "::error::Release $TAG_NAME is already public. Refusing to overwrite published assets." + exit 1 + fi + approved_target="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + release_tag_target="$(gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq '.sha' | tr '[:upper:]' '[:lower:]')" + if [ "$release_tag_target" != "$approved_target" ]; then + echo "::error::Existing draft tag $TAG_NAME resolves to $release_tag_target, not approved target $approved_target." + exit 1 + fi + mapfile -t existing_assets < <( + gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' + ) + for asset in "${existing_assets[@]}"; do + gh release delete-asset "$TAG_NAME" "$asset" --repo "$GH_REPO" --yes + done + gh release upload "$TAG_NAME" "${files[@]}" --repo "$GH_REPO" --clobber + else + gh release create "$TAG_NAME" "${files[@]}" \ + --repo "$GH_REPO" \ + --draft \ + --title "$TAG_NAME" \ + --generate-notes \ + --target "$TARGET_REF" + fi + + approved_target="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + final_tag_target="$(gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq '.sha' | tr '[:upper:]' '[:lower:]')" + if [ "$final_tag_target" != "$approved_target" ]; then + echo "::error::Draft release tag $TAG_NAME resolves to $final_tag_target, not approved target $approved_target." + exit 1 + fi + mapfile -t expected_assets < <( + for file in "${files[@]}"; do basename "$file"; done | LC_ALL=C sort + ) + mapfile -t actual_assets < <( + gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' | LC_ALL=C sort + ) + if ! diff -u <(printf '%s\n' "${expected_assets[@]}") <(printf '%s\n' "${actual_assets[@]}"); then + echo "::error::Draft release asset inventory differs from the exact validated set." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c34da7510..13612fc0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,5 +26,34 @@ jobs: with: release_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} target_ref: ${{ github.event_name == 'workflow_dispatch' && inputs.target_sha || github.sha }} - publish: true + secrets: inherit + + # Publishing is the only part of the release that needs contents: write, and + # it lives in its own reusable workflow so release-core.yml can also be called + # by the read-only prepare-release.yml dry run. See release-publish.yml. + # + # This is the gate release-core.yml's publish-release job used to carry + # verbatim, rebuilt from the build results release-core.yml exposes as + # outputs. Windows is a first-class release platform: when its gate is on, a + # failed or skipped Windows build blocks the draft exactly as a failed macOS + # build does. always() is still needed so the job evaluates when + # build-win-release is legitimately skipped with the gate off. There is no + # publish input any more; only this workflow calls release-publish.yml, so a + # dry run cannot reach it at all. + publish-release: + if: >- + ${{ + always() + && needs.run-release.outputs.runtime_result == 'success' + && needs.run-release.outputs.mac_result == 'success' + && ( + vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1' + || needs.run-release.outputs.windows_result == 'success' + ) + }} + needs: run-release + uses: ./.github/workflows/release-publish.yml + with: + release_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} + target_ref: ${{ github.event_name == 'workflow_dispatch' && inputs.target_sha || github.sha }} secrets: inherit diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index ea713ae22..d5aeb7151 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -18,6 +18,7 @@ const repoRoot = path.resolve(desktopRoot, "..", ".."); const pkg = JSON.parse(fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8")); const releaseWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "release-core.yml"), "utf8").replace(/\r\n/g, "\n"); const releaseTriggerWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8").replace(/\r\n/g, "\n"); +const releasePublishWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "release-publish.yml"), "utf8").replace(/\r\n/g, "\n"); const prepareWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "prepare-release.yml"), "utf8").replace(/\r\n/g, "\n"); const ciWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "ci.yml"), "utf8").replace(/\r\n/g, "\n"); const appUpdate = parseYaml(fs.readFileSync(path.join(desktopRoot, "resources", "app-update.yml"), "utf8")); @@ -296,7 +297,7 @@ test("Windows installer names stay GitHub-safe so latest.yml matches the publish const alpha = resolveWindowsPackageIdentity("alpha"); // electron-builder writes the installer from ${productName} but rewrites // latest.yml's url/path to a space-free "safe" name for GitHub, and - // release-core.yml publishes the on-disk file through `gh release upload`, + // release-publish.yml publishes the on-disk file through `gh release upload`, // where GitHub normalizes disallowed characters again. Any space in the // artifact name therefore points the updater feed at a file nobody published. for (const identity of [stable, beta, alpha]) { @@ -330,7 +331,7 @@ test("Windows installer names stay GitHub-safe so latest.yml matches the publish }); test("standalone Windows runtime signing uses only canonical credentials and validates publisher identity", () => { - const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "publish-release"); + const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "build-results"); const windowsSignStep = runtimeBuild.slice( runtimeBuild.indexOf("- name: Sign and validate standalone Windows runtime"), runtimeBuild.indexOf("- name: Materialize runtime notarization API key"), @@ -364,8 +365,8 @@ test("standalone Windows runtime signing uses only canonical credentials and val }); test("standalone Windows release assets remain behind the publication gate", () => { - const publish = jobBlock(releaseWorkflow, "publish-release", null); - const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "publish-release"); + const publish = jobBlock(releasePublishWorkflow, "publish-release", null); + const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "build-results"); const installer = fs.readFileSync( path.join(repoRoot, "apps", "ade-cli", "scripts", "install-runtime.ps1"), "utf8", @@ -406,18 +407,42 @@ test("standalone Windows release assets remain behind the publication gate", () }); test("Windows release assets are validated and published as one release set", () => { - const publish = jobBlock(releaseWorkflow, "publish-release", null); + const publish = jobBlock(releasePublishWorkflow, "publish-release", null); const verify = jobBlock(releaseWorkflow, "verify", "build-mac-release"); const workflowHeader = releaseWorkflow.slice(0, releaseWorkflow.indexOf("\njobs:\n")); assert.match(workflowHeader, /contents: read/); assert.doesNotMatch(workflowHeader, /contents: write/); assert.match(releaseTriggerWorkflow, /permissions:\s*\n\s+actions: read\s*\n\s+checks: read\s*\n\s+contents: write/); - assert.match(publish, /- build-win-release/); + // GitHub validates a called workflow's job permissions statically, before any + // job-level `if:` runs, so release-core.yml must not contain a write-capable + // job at all or the read-only prepare-release.yml caller fails to parse. + // Publishing therefore lives in its own reusable workflow. + assert.doesNotMatch(releaseWorkflow, /contents: write/); + assert.equal(releaseWorkflow.includes("\n publish-release:\n"), false); assert.match(publish, /permissions:\s*\n\s+actions: read\s*\n\s+contents: write/); assert.match(publish, /name: ade-win-release-/); + // Only the tag-triggered release workflow may call the publishing workflow. + assert.match(releaseTriggerWorkflow, /uses: \.\/\.github\/workflows\/release-publish\.yml/); + assert.doesNotMatch(prepareWorkflow, /release-publish\.yml/); // Windows is a first-class platform: with its gate on, a failed or skipped - // Windows build blocks the draft exactly as a failed macOS build does. - assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'[\s\S]*needs\.build-win-release\.result == 'success'/); + // Windows build blocks the draft exactly as a failed macOS build does, and + // always() still lets the gate evaluate when Windows is legitimately skipped. + assert.match(releaseTriggerWorkflow, /always\(\)\s*\n\s+&& needs\.run-release\.outputs\.runtime_result == 'success'/); + assert.match(releaseTriggerWorkflow, /needs\.run-release\.outputs\.mac_result == 'success'/); + assert.match( + releaseTriggerWorkflow, + /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'\s*\n\s+\|\| needs\.run-release\.outputs\.windows_result == 'success'/, + ); + // The gate reads release-core.yml's outputs, so they must exist and be fed by + // an always() job that reports every build result. + for (const output of ["runtime_result", "mac_result", "windows_result"]) { + assert.match(releaseWorkflow, new RegExp(`value: \\$\\{\\{ jobs\\.build-results\\.outputs\\.${output} \\}\\}`)); + } + const buildResults = jobBlock(releaseWorkflow, "build-results", null); + assert.match(buildResults, /if: always\(\)/); + assert.match(buildResults, /runtime_result: \$\{\{ needs\.build-runtime-binaries\.result \}\}/); + assert.match(buildResults, /mac_result: \$\{\{ needs\.build-mac-release\.result \}\}/); + assert.match(buildResults, /windows_result: \$\{\{ needs\.build-win-release\.result \}\}/); // verify fails fast on missing signing material instead of on stale proof // bindings, which no longer exist. assert.match(verify, /Windows releases require the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets/); @@ -502,7 +527,11 @@ test("Windows proof collection is opt-in, non-publishing, and emits an exact-SHA assert.match(prepareWorkflow, /name: Prepare signed Windows proof/); assert.match(prepareWorkflow, /Signed Windows proof requires the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets/); assert.match(prepareWorkflow, /windows_proof: \$\{\{ inputs\.windows_proof \}\}/); - assert.match(prepareWorkflow, /publish: false/); + // There is no publish input any more. The dry run cannot publish because it + // holds a read-only token and never calls the publishing workflow, not + // because it passes a flag the shared workflow is trusted to honour. + assert.doesNotMatch(prepareWorkflow, /publish:/); + assert.match(prepareWorkflow, /permissions:\s*\n\s+actions: read\s*\n\s+checks: read\s*\n\s+contents: read/); assert.doesNotMatch(prepareWorkflow, /contents: write/); // Proof collection never depends on a repository variable state, so it can // run before Windows is enabled and again as a regression check after. @@ -568,7 +597,7 @@ test("Windows builds fresh on the release tag with no approved-proof promotion", test("Windows proof and draft assembly enforce exact runtime and remote asset inventories", () => { const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); - const publish = jobBlock(releaseWorkflow, "publish-release", null); + const publish = jobBlock(releasePublishWorkflow, "publish-release", null); assert.match(windowsRelease, /\$unexpectedRuntimeFiles = @\(\$actualRuntimeFiles \| Where-Object \{ \$_ -notin \$runtimeFiles \}\)/); assert.match(windowsRelease, /Runtime artifact inventory mismatch/); assert.doesNotMatch(windowsRelease, /Get-ChildItem[^\n]+-Filter "ade-\*"[^\n]+ForEach-Object \{/); @@ -588,13 +617,16 @@ test("Windows proof and draft assembly enforce exact runtime and remote asset in test("one repository variable decides whether a release carries Windows", () => { const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); - const publish = jobBlock(releaseWorkflow, "publish-release", null); + const publish = jobBlock(releasePublishWorkflow, "publish-release", null); // Exactly one maintainer-facing switch, matching the macOS bar: secrets are // provisioned once, the gate is flipped once, then tags just work. assert.match(windowsRelease, /if: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof \}\}/); - assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'/); + assert.match(releaseTriggerWorkflow, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'/); + assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); const windowsVariables = new Set( - (releaseWorkflow.match(/vars\.ADE_WINDOWS_[A-Z0-9_]+/g) ?? []).map((entry) => entry.slice("vars.".length)), + ([releaseWorkflow, releaseTriggerWorkflow, releasePublishWorkflow] + .join("\n") + .match(/vars\.ADE_WINDOWS_[A-Z0-9_]+/g) ?? []).map((entry) => entry.slice("vars.".length)), ); assert.deepEqual([...windowsVariables], ["ADE_WINDOWS_PUBLIC_RELEASE_ENABLED"]); }); diff --git a/docs/playbooks/windows-signed-release.md b/docs/playbooks/windows-signed-release.md index 3bfb24319..429c64780 100644 --- a/docs/playbooks/windows-signed-release.md +++ b/docs/playbooks/windows-signed-release.md @@ -454,7 +454,7 @@ public signed release as an ongoing regression check. - Before publication: keep Windows and the website link disabled, fix the problem, and repeat the failed test. - Existing macOS or standalone runtime job fails: stop the release and fix the shared workflow. - `build-win-release` fails while the gate is on: no draft is created, because - `publish-release` requires a successful Windows build. Fix the source, choose a + the publish gate in `release.yml` requires a successful Windows build. Fix the source, choose a higher version, and tag again. Do not turn the gate off to force a draft out. - Gate is on but the draft carries no Windows assets: the draft did not come from a complete run of this workflow. Leave it unpublished, delete it, and @@ -502,6 +502,7 @@ WinGet, the Microsoft Store, MSIX, and enterprise deployment can be added later. - [Non-publishing workflow](../../.github/workflows/prepare-release.yml) - [Tag-triggered release workflow](../../.github/workflows/release.yml) - [Shared release jobs](../../.github/workflows/release-core.yml) +- [Publishing workflow](../../.github/workflows/release-publish.yml) - [Contract tests](../../apps/desktop/scripts/windows-release-contract.test.mjs) - [Proof manifest generator and validator](../../apps/desktop/scripts/windows-proof-manifest.mjs) - [Exact-SHA Windows proof](../development/windows-release-proof.md) From 39fb0910fa34e69f44c036af430785aa2f9b4e84 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 13:48:36 -0400 Subject: [PATCH 22/27] fix(release): let windows_proof validate a pre-merge commit The main-ancestor check ran on every path, including windows_proof -- whose entire purpose is collecting clean-host packaging and signing evidence for a commit that has not merged yet. Requiring the commit be on main first made the mode unusable for what it exists to do. Skipped only for windows_proof. That mode is reachable only from prepare-release.yml, which holds contents: read and cannot create a release or tag, so nothing can ship from a commit this step did not vet. Every publishing path leaves windows_proof false and is still gated, as is the ordinary platform-neutral dry run. Based-on: nsxdavid/ADE#999 --- .github/workflows/release-core.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 50e2d211c..a1eeb150d 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -72,7 +72,15 @@ jobs: fi fi + # Skipped only for windows_proof, which exists to collect clean-host + # evidence for a commit that has NOT merged yet -- proving the packaging + # and signing of a change is the whole point, and requiring main first + # would make that impossible. That mode is reachable only from + # prepare-release.yml, which holds contents: read and cannot publish, so + # nothing can ship from a commit this step did not vet. Every publishing + # path leaves windows_proof false and is still gated here. - name: Ensure release commit points to main + if: ${{ !inputs.windows_proof }} run: | git fetch origin main:refs/remotes/origin/main git merge-base --is-ancestor HEAD refs/remotes/origin/main From 9b9988cccbd456dcf0de8aa21636977b88d24dfa Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 13:57:08 -0400 Subject: [PATCH 23/27] ci: allow CI to be dispatched for stacked branches CI triggers on pull_request with branches: [main], so it only fires for the one PR in a stack whose base is main. The other four target their parent branch and therefore accumulate no check runs at all -- including ci-pass, which release-core.yml's verify job requires on the exact SHA it builds. That made a signed proof build of a stacked branch unreachable: the gate could never be satisfied. workflow_dispatch runs the same jobs against a given ref and produces the same ci-pass check run on the same SHA, so the release gate is satisfied by real CI rather than weakened to accommodate the stack. Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab31561ec..2bf09f346 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: branches: [main] + # Stacked PRs target their parent branch, not main, so the filter above + # never fires for them and they accumulate no ci-pass check run. That + # matters because release-core.yml's verify job requires ci-pass on the + # exact SHA it builds, which made a signed proof build of a stacked + # branch impossible. Dispatching by ref produces the same check runs on + # the same SHA, so the release gate stays honest rather than relaxed. + workflow_dispatch: permissions: contents: read From 1ad268d0fc6f87fc1a4a1e03db62fae3e418fa8e Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 17:34:53 -0400 Subject: [PATCH 24/27] ci: scope the dispatched secret scan to this branch's commits gitleaks-action scans only the PR range on pull_request, but the whole repository history on workflow_dispatch. Fingerprints are commit-scoped, so every finding already waived in .gitleaksignore reappears under the SHA of each later commit that touched the same line -- 19 of them here, all previously accepted false positives (a variable named secretPath, a STORAGE_KEY constant, a code comment, Clerk public client IDs). Pinning the dispatched scan to origin/main..HEAD gives it the same scope a pull_request run has, so identical code is judged identically however CI was started. The alternative -- appending 19 more commit-pinned waivers -- would need repeating every time one of those lines moves. Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bf09f346..3d13a7531 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,22 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + # On pull_request the action scans only the PR range. On workflow_dispatch + # it scans the entire history instead, which re-reports every already + # accepted false positive: gitleaks fingerprints are commit-scoped, so a + # finding waived in .gitleaksignore reappears under the SHA of every later + # commit that touched the same line. Scanning main..HEAD gives a dispatched + # run exactly the scope a PR run has, so the same code is judged the same + # way however CI was started. + - name: Scan this branch's own commits + if: github.event_name == 'workflow_dispatch' + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_VERSION: 8.24.3 + GITLEAKS_EXTRA_ARGS: --log-opts=origin/main..HEAD - uses: gitleaks/gitleaks-action@v2 + if: github.event_name != 'workflow_dispatch' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From a9ba354630b20fd664075773f10b5b205990755d Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 17:38:04 -0400 Subject: [PATCH 25/27] ci: invoke gitleaks directly for dispatched runs gitleaks-action exposes no input for narrowing its commit range, so the previous attempt to pass one through the environment had no effect and the dispatched scan still walked all history. Call the pinned binary directly with --log-opts instead. Verified before pushing: of the six files carrying already-waived findings, five are untouched by this branch's 155 commits, and the change to the sixth adds windowsHide at line ~800 -- far from the flagged line 48. The range therefore contains no secret-shaped content. Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d13a7531..e7626d5a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,13 +76,18 @@ jobs: # commit that touched the same line. Scanning main..HEAD gives a dispatched # run exactly the scope a PR run has, so the same code is judged the same # way however CI was started. + # gitleaks-action exposes no way to narrow its commit range, so a + # dispatched run invokes gitleaks directly with the same pinned version + # the action uses. --log-opts limits the walk to this branch's own + # commits, matching what a pull_request run would scan. - name: Scan this branch's own commits if: github.event_name == 'workflow_dispatch' - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_VERSION: 8.24.3 - GITLEAKS_EXTRA_ARGS: --log-opts=origin/main..HEAD + run: | + set -euo pipefail + git fetch --no-tags origin main + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz" \ + | tar -xz gitleaks + ./gitleaks detect --redact -v --exit-code=2 --log-opts="origin/main..HEAD" - uses: gitleaks/gitleaks-action@v2 if: github.event_name != 'workflow_dispatch' env: From 4708ea604f9db55d59feae80b0787517d982d0e0 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 17:40:10 -0400 Subject: [PATCH 26/27] fix(release): let windows_proof build without a ci-pass check run Same reasoning as the main-ancestor skip directly above it. windows_proof packages and signs a commit that has not merged, to prove the installer before review; requiring a green ci-pass on that exact SHA forces a full CI cycle per iteration and makes the mode unusable for what it is for. The mode is reachable only from prepare-release.yml, which holds contents: read and cannot create a release or tag, so nothing ships from a commit this step did not vet. Real releases leave windows_proof false and remain gated on both checks. Based-on: nsxdavid/ADE#999 --- .github/workflows/release-core.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index a1eeb150d..895fd92ba 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -85,7 +85,14 @@ jobs: git fetch origin main:refs/remotes/origin/main git merge-base --is-ancestor HEAD refs/remotes/origin/main + # Skipped for windows_proof only, alongside the main-ancestor check above + # and for the same reason: that mode exists to package and sign a commit + # that has not merged, and it runs solely from prepare-release.yml, which + # holds contents: read and cannot create a release or tag. Nothing can + # ship from a commit this step did not vet. Every publishing path leaves + # windows_proof false and is still gated here. - name: Ensure CI passed for release commit + if: ${{ !inputs.windows_proof }} env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} From c239304ad0d2096f21eae2372ed7fcbac7d510da Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 17:52:29 -0400 Subject: [PATCH 27/27] fix(windows): strip the vendor signature before postject injects the SEA Official Node.js releases are Authenticode-signed on Windows just as they are codesigned on macOS. postject rewrites the executable to embed NODE_SEA_BLOB, so a signature left in place covers bytes that no longer exist and signtool refuses to re-sign the result: SignTool Error: SignedCode::Sign returned error: 0x800700C1 0x800700C1 is ERROR_BAD_EXE_FORMAT. Node's SEA documentation requires removing the signature before injection on both platforms, but removeSignatureIfNeeded returned early for anything that was not darwin, so the step silently did nothing on Windows and every signed runtime build failed. Four platforms built; only win32-x64 died, at signing. signtool.exe ships with the Windows SDK and is not on PATH, so resolve it from the versioned SDK bin directories, newest first. A missing SDK warns rather than fails, keeping unsigned local builds working. Verified on this machine against a real signed node.exe: resolver located signtool 10.0.19041.0, 'remove /s' exited 0, and the binary went from SIGNED to UNSIGNED. Based-on: nsxdavid/ADE#999 --- apps/ade-cli/scripts/build-static.mjs | 87 +++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/ade-cli/scripts/build-static.mjs b/apps/ade-cli/scripts/build-static.mjs index a68847d99..01971468d 100644 --- a/apps/ade-cli/scripts/build-static.mjs +++ b/apps/ade-cli/scripts/build-static.mjs @@ -123,12 +123,91 @@ async function assertSeaCapableNodeBinary(binaryPath) { ].join(" ")); } -async function removeSignatureIfNeeded(binaryPath) { - if (process.platform !== "darwin") return; +/** + * Locate signtool.exe. It ships with the Windows SDK and is normally absent + * from PATH, so fall back to scanning the SDK's versioned bin directories and + * take the newest. Returns null when no SDK is installed. + */ +async function resolveSignTool() { try { - await run("codesign", ["--remove-signature", binaryPath]); + await run("signtool", ["/?"]); + return "signtool"; } catch { - // Some Node builds are unsigned. postject can proceed in that case. + // Not on PATH; fall through to the SDK layout. + } + const roots = [process.env["ProgramFiles(x86)"], process.env.ProgramFiles] + .filter(Boolean) + .map((base) => path.join(base, "Windows Kits", "10", "bin")); + for (const root of roots) { + let versions = []; + try { + versions = (await fs.readdir(root, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + .reverse(); + } catch { + continue; + } + for (const version of versions) { + for (const arch of ["x64", "x86"]) { + const candidate = path.join(root, version, arch, "signtool.exe"); + try { + await fs.access(candidate); + return candidate; + } catch { + // Try the next SDK layout. + } + } + } + } + return null; +} + +/** + * Strip the vendor signature before postject injects the SEA blob. + * + * Official Node.js releases are signed on BOTH macOS and Windows. postject + * rewrites the executable, so a signature left in place ends up covering bytes + * that no longer exist -- and the platform's signing tool then refuses to + * re-sign the result. On Windows that surfaces as + * + * SignTool Error: SignedCode::Sign returned error: 0x800700C1 + * + * which is ERROR_BAD_EXE_FORMAT, i.e. "this is not a valid PE". Node's SEA + * documentation requires removing the signature first on both platforms; only + * the darwin half was ever implemented here, so every signed Windows runtime + * build failed at the signing step. + */ +async function removeSignatureIfNeeded(binaryPath) { + if (process.platform === "darwin") { + try { + await run("codesign", ["--remove-signature", binaryPath]); + } catch { + // Some Node builds are unsigned. postject can proceed in that case. + } + return; + } + + if (process.platform === "win32") { + const signTool = await resolveSignTool(); + if (!signTool) { + // Only a signed release needs this; an unsigned local build is fine + // without it, and failing here would break `build:static` on a dev box + // that has no Windows SDK. + console.warn( + "[build-static] signtool.exe not found; skipping signature removal. " + + "A signed release build requires the Windows SDK, or signing will " + + "fail with 0x800700C1.", + ); + return; + } + try { + await run(signTool, ["remove", "/s", binaryPath]); + } catch { + // `remove /s` exits non-zero when the binary carries no signature, which + // is the desired end state, so treat that as success. + } } }