Merge pull request #6340 from deepseek-harness/fix/windows-runner-windowless

fix(desktop): launch Windows runner without a console
This commit is contained in:
winewill authored and GitHub committed 2026-10-19 11:23:33 +08:00
commit c39ce9e5e8
16 files changed
+418 -24

No files matched your search

+4 -1
View File
@@ -570,12 +570,15 @@ jobs:
- name: Parse Windows runner supervision scripts
shell: pwsh
run: |
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1')) {
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) {
$tokens = $null
$parseErrors = $null
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw ($parseErrors -join "`n") }
}
- name: Verify windowless Windows runner launcher
shell: pwsh
run: ./apps/desktop/tests/windows-runner-launcher.ps1
- name: Run Windows observational gates on built artifacts
id: observational
continue-on-error: true
+6
View File
@@ -74,6 +74,9 @@ jobs:
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Check local publication configuration before dependency installation
working-directory: desktop-publisher
run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs
- run: pnpm install --frozen-lockfile
working-directory: desktop-publisher
- name: Resolve retained package and approval environment
@@ -115,6 +118,9 @@ jobs:
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Check local publication configuration before dependency installation
working-directory: desktop-publisher
run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs
- run: pnpm install --frozen-lockfile
working-directory: desktop-publisher
- name: Verify local package without upload credentials
+10 -4
View File
@@ -12,14 +12,20 @@
en: 470bdf1487a739f9
zh: cd80330eaa8f730c
/desktop-ci-publication/table-of-contents:
en: 1b6a4d7146879eec
zh: f2520fc412cd2d2a
en: 34283bf8397f6836
zh: 1798bd921c3adf6c
/desktop-ci-publication/setup-sequence:
en: 2be541ef35a092a4
zh: 77d87c00deb45758
/desktop-ci-publication/runner-labels-and-queue-diagnosis:
en: 0478d8e35f9a3d55
zh: 0aa3fd828bc02691
/desktop-ci-publication/machine-setup:
en: f80388c2b1ffef4c
zh: f242ab2316bf38a1
/desktop-ci-publication/package-and-publish:
en: 0a26537845e89dda
zh: 307378bb65e24650
en: f7c12d343891e547
zh: da596ee1366c2046
/desktop-ci-publication/recovery-and-records:
en: 2e1e6b16d0d6bd41
zh: 6ff4ad5a8f1f57b4
+32 -1
View File
@@ -12,11 +12,42 @@ Publish a completed package from its original machine without downloading its in
## Table of Contents
- [Setup sequence](#setup-sequence)
- [Runner labels and queue diagnosis](#runner-labels)
- [Machine setup](#machine-setup)
- [Package and publish](#package-and-publish)
- [Recovery and records](#recovery-and-records)
- [Restore a channel](#restore-a-channel)
<a id="setup-sequence"></a>
## Setup sequence
Use this sequence when onboarding a dedicated machine. The deployment operator owns registration, credentials, approvals and real upload acceptance; repository checks do not establish that a machine can publish.
1. Prepare a runner-owned checkout and an external configuration directory. Follow [Windows machine setup and signing](../../windows-signing/README.md#machine-setup) or [macOS runner and signing setup](../../packaging/README.md#macos-actions) for tools, templates and platform credentials.
2. Register the runner using GitHub **Settings → Actions → Runners**, or the organization's runner administration page. Record its exact registered name, grant this repository access to its runner group, and assign the [required labels](#runner-labels). Keep the default labels during registration.
3. Install and start the platform supervisor described in the platform guide. Windows Task Scheduler and the macOS LaunchAgent require the configured user's login; they do not provide unattended startup before login. Confirm both GitHub connectivity and `Listening for Jobs`, then check the runner is online in GitHub.
4. Complete [machine publication configuration](#machine-setup), the repository's `DESKTOP_PUBLISH_RUNNERS` variable, and the two deployment Environments. The repository administrator owns routing and reviewers; the COS administrator owns bucket configuration and object read/write permissions; production also needs the configured GitHub App.
5. Run a signed Windows or notarized macOS **test** package using [Package and publish](#package-and-publish). Confirm the packaging job used the intended registered runner and retained its files. Dispatch publication from `master` with that run ID and attempt; confirm `prepare` actually starts on the same machine and produces the approval summary.
6. Inspect the summary's environment, version, channel and URLs before approving the test upload. Verify the publication record, public installer download and update feed. Test acceptance does not establish production permissions: provision and approve production separately. Keep the run/attempt and acceptance results for recovery.
<a id="runner-labels"></a>
## Runner labels and queue diagnosis
GitHub matches every label in a job's `runs-on` list. The [default-label rules](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/use-in-a-workflow) explain why registration with `--no-default-labels` can leave an online runner ineligible. The current workflows require:
| Job | Required labels on the same runner |
| --- | --- |
| Windows packaging | `dsh-win-package-trial` |
| macOS packaging | `self-hosted`, `macOS`, `ARM64`, `dsh-mac-package-trial` |
| Publication `prepare` and `publish` | `self-hosted` and the selected `DESKTOP_PUBLISH_RUNNERS` entry's `label` |
A runner with only `dsh-win-package-trial` can package Windows but cannot accept publication. Its routing entry does not add labels to its GitHub registration. Have a runner administrator correct the registered labels; changing the route's `target` or restarting a healthy listener does not supply a missing label.
For a queued job, compare the job page's requested labels with **Settings → Actions → Runners**, then check repository/group access and whether the matching runner is online and idle. A running supervisor only establishes that its process exists. A successful hosted `route` job only establishes package selection; it does not establish that `prepare` can be assigned. A deployment waiting for approval needs its Environment reviewer, while an assigned job that fails needs its step logs. Diagnose these states separately before restarting anything.
<a id="machine-setup"></a>
## Machine setup
@@ -41,7 +72,7 @@ Select the platform's `Package Desktop` workflow, preset, source, channel and ve
Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication.
Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v<version>` at the recorded packaging commit; an existing tag must name the same commit.
Before installing dependencies on the original runner, preparation and the approved publish job check that the selected `publishing/<preset>.json` is readable JSON. Missing configuration reports its path; restore it and retry publication with the same package. Full configuration and signature validation still run afterward. Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v<version>` at the recorded packaging commit; an existing tag must name the same commit.
<a id="recovery-and-records"></a>
+32 -1
View File
@@ -12,11 +12,42 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安
## 目录
- [配置顺序](#setup-sequence)
- [Runner 标签与排队排查](#runner-labels)
- [机器配置](#machine-setup)
- [打包与发布](#package-and-publish)
- [恢复与记录](#recovery-and-records)
- [恢复频道](#restore-a-channel)
<a id="setup-sequence"></a>
## 配置顺序
首次配置专用机器时按此顺序操作。部署操作员负责注册、凭据、审批和真实上传验收;仓库检查不能证明机器已具备发布能力。
1. 准备 runner 专用 checkout 和仓库外的配置目录。按 [Windows 机器与签名配置](../../windows-signing/README.zh.md#machine-setup)或 [macOS runner 与签名配置](../../packaging/README.zh.md#macos-actions)准备工具、模板和平台凭据。
2. 通过 GitHub **Settings → Actions → Runners** 或组织的 runner 管理页面注册 runner。记录准确的注册名称,允许本仓库访问其 runner group,并分配[必需标签](#runner-labels)。注册时保留默认标签。
3. 按平台指南安装并启动守护任务。Windows 计划任务和 macOS LaunchAgent 都要求配置的用户登录,不能在登录前无人值守启动。确认已连接 GitHub 且出现 `Listening for Jobs`,再检查 GitHub 中 runner 在线。
4. 完成[本机发布配置](#machine-setup)、仓库变量 `DESKTOP_PUBLISH_RUNNERS` 和两个部署 Environment。仓库管理员负责路由与审批人;COS 管理员负责桶配置及对象读写权限;production 还需要配置 GitHub App。
5. 按[打包与发布](#package-and-publish)完成一次 Windows 已签名或 macOS 已公证的 **test** 打包。确认打包 job 使用预期的注册 runner 并留存文件。从 `master` 发起发布,填写该 run ID 和 attempt;确认 `prepare` 确实在同一台机器上启动并生成审批摘要。
6. 审核摘要中的环境、版本、频道和 URL 后,再批准测试上传。验证发布记录、公开安装包下载和升级 feed。test 验收不能证明 production 权限可用:production 需独立配置与审批。保留 run/attempt 和验收结果,供故障恢复使用。
<a id="runner-labels"></a>
## Runner 标签与排队排查
GitHub 要求 runner 同时匹配 job 的 `runs-on` 列表中的全部标签。[默认标签规则](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/use-in-a-workflow)说明了为什么使用 `--no-default-labels` 注册后,runner 即使在线也可能无法接单。当前工作流要求:
| Job | 同一台 runner 必须具备的标签 |
| --- | --- |
| Windows 打包 | `dsh-win-package-trial` |
| macOS 打包 | `self-hosted`、`macOS`、`ARM64`、`dsh-mac-package-trial` |
| 发布 `prepare` 和 `publish` | `self-hosted`,以及选中的 `DESKTOP_PUBLISH_RUNNERS` 条目中的 `label` |
只有 `dsh-win-package-trial` 的 runner 可以打包 Windows,却不能接发布任务。路由条目不会给 GitHub 中注册的 runner 添加标签。需由 runner 管理员修正注册标签;修改路由的 `target` 或重启正常监听的进程都不能补齐缺失标签。
job 排队时,先对照 job 页面请求的标签与 **Settings → Actions → Runners** 中的标签,再检查仓库/group 访问权限,以及匹配的 runner 是否在线且空闲。守护任务运行只说明进程存在。托管的 `route` job 成功只说明已选定打包任务,不代表 `prepare` 能被分配。部署等待审批时需要 Environment 审批人操作,已经分配但失败的 job 则需查看步骤日志。重启前先区分这些状态。
<a id="machine-setup"></a>
## 机器配置
@@ -41,7 +72,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。
上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v<version>`;已有 tag 必须指向同一提交。
在原 runner 安装依赖前,准备阶段和审批后的发布 job 都会检查所选 `publishing/<preset>.json` 是否为可读取的 JSON。配置缺失时会报告路径;补齐后可使用同一个包重试发布。之后仍会执行完整配置与签名校验。上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v<version>`;已有 tag 必须指向同一提交。
<a id="recovery-and-records"></a>
@@ -0,0 +1,6 @@
/**
* Check the selected machine configuration without authorizing publication or reading secrets.
* @param env Selected package or approved route and machine directories.
* @returns Resolves when the selected configuration is a readable JSON object; full validation remains in the publisher.
*/
export function preflightDesktopPublishLocal(env: NodeJS.ProcessEnv): Promise<void>
@@ -0,0 +1,43 @@
/** Reject missing local publication configuration before dependency installation; never open credential files. */
import { readFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, resolve } from 'node:path'
async function readJson(path, subject) {
try {
return JSON.parse(await readFile(path, 'utf8'))
} catch {
throw new Error(`desktop publish: ${subject} is missing, unreadable or invalid JSON: ${path}. Restore this file on the original runner, then retry publication; repackaging is not required.`)
}
}
/**
* Check the selected machine configuration without authorizing publication or reading secrets.
* @param {NodeJS.ProcessEnv} env Selected package or approved route and machine directories.
* @returns {Promise<void>} Resolves when the selected configuration is a readable JSON object; full validation remains in the publisher.
*/
export async function preflightDesktopPublishLocal(env) {
const root = env.DSH_PACKAGING_CONFIG_DIR
if (!root || !isAbsolute(root)) throw new Error('desktop publish: set DSH_PACKAGING_CONFIG_DIR to the absolute machine packaging configuration directory')
let preset
if (env.DESKTOP_PUBLISH_ROUTE) {
preset = JSON.parse(env.DESKTOP_PUBLISH_ROUTE).preset
} else {
const selection = JSON.parse(env.DESKTOP_PACKAGE_SELECTION || '{}')
const run = selection?.run
if (!Number.isSafeInteger(run?.id) || run.id <= 0 || !Number.isSafeInteger(run?.run_attempt) || run.run_attempt <= 0) {
throw new Error('desktop publish: selected package must identify a positive run ID and attempt')
}
const releases = env.DSH_DESKTOP_RELEASES_DIR || join(dirname(root), 'releases')
if (!isAbsolute(releases)) throw new Error('desktop publish: DSH_DESKTOP_RELEASES_DIR must be absolute')
const manifest = await readJson(join(releases, String(run.id), String(run.run_attempt), 'manifest.json'), 'retained package manifest')
preset = manifest?.source?.preset
}
if (preset !== 'test' && preset !== 'production') throw new Error('desktop publish: selected package preset must be test or production')
const path = join(root, 'publishing', `${preset}.json`)
const config = await readJson(path, 'publication configuration')
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error(`desktop publish: publication configuration must be a JSON object: ${path}`)
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
await preflightDesktopPublishLocal(process.env)
}
@@ -5,7 +5,6 @@ $digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.
$taskName = 'DSH-Packaging-' + $digest.Substring(0,16)
$log = Join-Path $PSScriptRoot ('run-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '-' + $PID + '.log')
try {
@{ Pid = $PID; Started = (Get-Process -Id $PID).StartTime.ToUniversalTime().Ticks } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'process.json')
$config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'config.json') -Raw | ConvertFrom-Json
$attemptFile = Join-Path $PSScriptRoot 'attempts.txt'
$attempts = [int](Get-Content -LiteralPath $attemptFile -Raw)
@@ -0,0 +1,75 @@
// Compiled as a Windows GUI executable so Task Scheduler never allocates a console.
using System;
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
internal static class WindowsRunnerLauncher
{
private static int Main(string[] args)
{
string root = AppDomain.CurrentDomain.BaseDirectory;
var owner = Process.GetCurrentProcess();
string log = Path.Combine(root, "launcher-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmssfffZ") + "-" + owner.Id + "-" + Guid.NewGuid().ToString("N"));
try
{
File.WriteAllText(Path.Combine(root, "process.json"), "{\"Pid\":" + owner.Id + ",\"Started\":" + owner.StartTime.ToUniversalTime().Ticks + "}");
if (args.Length != 1 || !Path.IsPathRooted(args[0]))
throw new ArgumentException("Expected the absolute PowerShell executable path.");
using (var output = File.Create(log + ".out.log"))
using (var errors = File.Create(log + ".err.log"))
using (var child = new Process())
{
child.StartInfo = new ProcessStartInfo
{
FileName = args[0],
Arguments = "-NoProfile -NonInteractive -File \"" + Path.Combine(root, "run.ps1") + "\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
child.Start();
Task stdout = child.StandardOutput.BaseStream.CopyToAsync(output);
Task stderr = child.StandardError.BaseStream.CopyToAsync(errors);
child.WaitForExit();
// Descendants can retain inherited pipe writers after the direct child exits.
if (!Task.WaitAll(new[] { stdout, stderr }, TimeSpan.FromSeconds(5)))
{
File.WriteAllText(log + ".drain.log", "Output drain timed out after child exit " + child.ExitCode + "; launcher logs may be incomplete. Periodic recovery remains governed by the restart budget.");
// Process exit releases pipe reads still held by background copy tasks.
return 5;
}
return child.ExitCode;
}
}
catch (Exception error)
{
// Launcher exceptions disable periodic starts, including failures before run.ps1 can consume its budget.
int result = 2;
try { DisableTask(root); }
catch (Exception disableError) { error = new AggregateException(error, disableError); result = 4; }
try { File.AppendAllText(log + ".err.log", error.ToString() + Environment.NewLine); }
catch (Exception)
{
// No console is available. Task Scheduler result 3 reports that diagnostics could not be written.
return result == 4 ? 4 : 3;
}
return result;
}
}
private static void DisableTask(string root)
{
// Keep task identity aligned with windows-runner-task.ps1 and run-windows-runner-task.ps1.
string runner = Directory.GetParent(root.TrimEnd(Path.DirectorySeparatorChar)).FullName.TrimEnd('\\', '/');
string taskName;
using (var hash = SHA256.Create())
taskName = "DSH-Packaging-" + BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(runner.ToUpperInvariant()))).Replace("-", "").Substring(0, 16);
dynamic scheduler = Activator.CreateInstance(Type.GetTypeFromProgID("Schedule.Service"));
scheduler.Connect();
scheduler.GetFolder("\\").GetTask(taskName).Enabled = false;
}
}
+6 -2
View File
@@ -37,12 +37,16 @@ if ($Action -eq 'install') {
} else {
$null = New-Item -ItemType Directory -Path $serviceDir
}
$compiler = Join-Path $env:SystemRoot 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'
if (-not (Test-Path -LiteralPath $compiler)) { throw 'Install requires the Windows .NET Framework 4.5+ compiler (Framework64/v4.0.30319/csc.exe).' }
$launcher = Join-Path $serviceDir 'launcher.exe'
& $compiler /nologo /target:winexe "/out:$launcher" (Join-Path $PSScriptRoot 'windows-runner-launcher.cs')
if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; verify .NET Framework 4.5+ is installed. No task was installed.' }
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1')
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1')
@{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8
Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0'
$arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -File "' + (Join-Path $serviceDir 'run.ps1') + '"'
$execution = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments -WorkingDirectory $RunnerRoot
$execution = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + $pwsh + '"') -WorkingDirectory $RunnerRoot
$principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited
$trigger = @(
(New-ScheduledTaskTrigger -AtLogOn -User $sid),
@@ -0,0 +1,45 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { preflightDesktopPublishLocal } from '../scripts/preflight-desktop-publish-local.mjs'
it('checks the retained preset before approval and the approved preset before upload without opening credentials', async () => {
const root = await mkdtemp(join(tmpdir(), 'publish-preflight-'))
try {
const configRoot = join(root, 'packaging')
const releases = join(root, 'custom-releases')
const manifest = join(releases, '42', '2')
await mkdir(manifest, { recursive: true })
await writeFile(join(manifest, 'manifest.json'), JSON.stringify({ source: { preset: 'test' } }))
const env = { DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_DESKTOP_RELEASES_DIR: releases,
DESKTOP_PACKAGE_SELECTION: JSON.stringify({ run: { id: 42, run_attempt: 2 } }) }
await expect(preflightDesktopPublishLocal(env)).rejects.toThrow(`publication configuration is missing, unreadable or invalid JSON: ${join(configRoot, 'publishing', 'test.json')}`)
await mkdir(join(configRoot, 'publishing'), { recursive: true })
await writeFile(join(configRoot, 'publishing', 'test.json'), JSON.stringify({ credentialsFile: join(root, 'absent-secret.json') }))
await expect(preflightDesktopPublishLocal(env)).resolves.toBeUndefined()
const approved = { ...env, DESKTOP_PUBLISH_ROUTE: JSON.stringify({ preset: 'production' }) }
await expect(preflightDesktopPublishLocal(approved)).rejects.toThrow('production.json')
await writeFile(join(configRoot, 'publishing', 'production.json'), '{secret-invalid-json')
await expect(preflightDesktopPublishLocal(approved)).rejects.toThrow('invalid JSON')
await writeFile(join(configRoot, 'publishing', 'production.json'), '[]')
await expect(preflightDesktopPublishLocal(approved)).rejects.toThrow('must be a JSON object')
await writeFile(join(configRoot, 'publishing', 'production.json'), '{}')
await expect(preflightDesktopPublishLocal(approved)).resolves.toBeUndefined()
await expect(preflightDesktopPublishLocal({ ...approved, DESKTOP_PUBLISH_ROUTE: '{"preset":"../test"}' })).rejects.toThrow('preset must be')
await expect(preflightDesktopPublishLocal({ ...env, DESKTOP_PACKAGE_SELECTION: '{"run":{"id":"../42","run_attempt":2}}' })).rejects.toThrow('positive run ID')
await expect(preflightDesktopPublishLocal({ ...env, DSH_DESKTOP_RELEASES_DIR: '' })).rejects.toThrow('retained package manifest')
await expect(preflightDesktopPublishLocal({ ...env, DSH_PACKAGING_CONFIG_DIR: 'relative' })).rejects.toThrow('absolute machine')
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('runs the local preflight before dependency installation in both self-hosted jobs', async () => {
const workflow = await readFile('.github/workflows/desktop-publish.yml', 'utf8')
for (const job of ['prepare', 'publish']) {
const section = workflow.split(`\n ${job}:`)[1]!.split(/\n \w+:/u)[0]!
expect(section.indexOf('node apps/desktop/scripts/preflight-desktop-publish-local.mjs')).toBeGreaterThan(0)
expect(section.indexOf('node apps/desktop/scripts/preflight-desktop-publish-local.mjs')).toBeLessThan(section.indexOf('pnpm install --frozen-lockfile'))
}
})
@@ -0,0 +1,90 @@
# Windowless launcher acceptance without Task Scheduler, GitHub registration or signing credentials.
$ErrorActionPreference = 'Stop'
if (-not $IsWindows) { throw 'Requires PowerShell 7 on Windows.' }
$root = Join-Path ([IO.Path]::GetTempPath()) ('dsh-launcher-' + [guid]::NewGuid().ToString('N'))
$taskRoot = Join-Path $root 'task with spaces'
$null = New-Item -ItemType Directory -Path $taskRoot
$launcher = Join-Path $taskRoot 'launcher.exe'
$compiler = Join-Path $env:SystemRoot 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'
& $compiler /nologo /target:winexe "/out:$launcher" (Join-Path $PSScriptRoot '../scripts/windows-runner-launcher.cs')
if ($LASTEXITCODE -ne 0) { throw 'Launcher compilation failed.' }
$image = [IO.File]::ReadAllBytes($launcher)
$peOffset = [BitConverter]::ToInt32($image, 0x3c)
if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' }
@'
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }'
[Console]::WriteLine('console=' + [ConsoleProbe]::GetConsoleWindow().ToInt64())
[Console]::Error.WriteLine('launcher stderr probe')
exit 37
'@ | Set-Content -LiteralPath (Join-Path $taskRoot 'run.ps1')
function Invoke-Launcher([string]$Executable, [int]$ExpectedExit) {
$existingLogs = @(Get-ChildItem -LiteralPath $taskRoot -Filter 'launcher-*.err.log' | ForEach-Object FullName)
$process = [Diagnostics.Process]::new()
$process.StartInfo = [Diagnostics.ProcessStartInfo]::new($launcher)
$process.StartInfo.UseShellExecute = $false
$process.StartInfo.CreateNoWindow = $true
$process.StartInfo.ArgumentList.Add($Executable)
$started = $false
$primaryFailure = $null
try {
$started = $process.Start()
if (-not $process.WaitForExit(30000)) { throw 'Launcher did not exit within 30 seconds.' }
if ($process.ExitCode -ne $ExpectedExit) { throw "Expected exit $ExpectedExit, got $($process.ExitCode)." }
$logs = @(Get-ChildItem -LiteralPath $taskRoot -Filter ('launcher-*-'+$process.Id+'-*.err.log') | Where-Object { $_.FullName -notin $existingLogs })
if ($logs.Count -ne 1) { throw 'Expected a unique launcher diagnostic.' }
return $logs[0].FullName.Substring(0, $logs[0].FullName.Length - '.err.log'.Length)
} catch { $primaryFailure = $_; throw } finally {
try {
if ($started -and -not $process.HasExited) {
$process.Kill($true)
if (-not $process.WaitForExit(30000)) { throw 'Launcher teardown did not complete.' }
}
} catch {
if (-not $primaryFailure) { throw }
Write-Warning "Launcher cleanup also failed: $_"
} finally {
$process.Dispose()
}
}
}
$log = Invoke-Launcher (Join-Path $PSHOME 'pwsh.exe') 37
if ((Get-Content -LiteralPath ($log + '.out.log') -Raw).Trim() -ne 'console=0') { throw 'Child allocated a console or stdout was lost.' }
if ((Get-Content -LiteralPath ($log + '.err.log') -Raw).Trim() -ne 'launcher stderr probe') { throw 'Child stderr was lost.' }
$log = Invoke-Launcher (Join-Path $root 'missing.exe') 4
if (-not (Get-Content -LiteralPath ($log + '.err.log') -Raw).Contains('Win32Exception')) { throw 'Startup failure diagnostic was lost.' }
@'
$info = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe'))
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
$info.ArgumentList.Add('-NoProfile')
$info.ArgumentList.Add('-NonInteractive')
$info.ArgumentList.Add('-Command')
$info.ArgumentList.Add('Start-Sleep -Seconds 120')
$orphan = [Diagnostics.Process]::Start($info)
@{ Pid = $orphan.Id; Started = $orphan.StartTime.ToUniversalTime().Ticks } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'orphan.json')
exit 37
'@ | Set-Content -LiteralPath (Join-Path $taskRoot 'run.ps1')
$orphan = $null
$primaryFailure = $null
try {
$log = Invoke-Launcher (Join-Path $PSHOME 'pwsh.exe') 5
if (-not (Get-Content -LiteralPath ($log + '.drain.log') -Raw).Contains('child exit 37')) { throw 'Inherited writer did not exercise bounded draining.' }
} catch { $primaryFailure = $_; throw } finally {
try {
$recordFile = Join-Path $taskRoot 'orphan.json'
if (Test-Path -LiteralPath $recordFile) {
$record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json
$orphan = Get-Process -Id $record.Pid -ErrorAction SilentlyContinue
if ($orphan -and $orphan.StartTime.ToUniversalTime().Ticks -eq $record.Started) {
$orphan.Kill($true)
if (-not $orphan.WaitForExit(30000)) { throw 'Orphan fixture did not exit.' }
}
}
} catch {
if (-not $primaryFailure) { throw }
Write-Warning "Orphan cleanup also failed: $_"
} finally {
if ($orphan) { $orphan.Dispose() }
}
}
Write-Output "PASS: GUI executable, no child console, exit propagation, stdout/stderr, startup failure and bounded inherited-pipe drain. Evidence: $root"
+57 -6
View File
@@ -7,11 +7,13 @@ $source = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts'))
$runner = Join-Path $root 'runner with spaces'
$null = New-Item -ItemType Directory -Path $runner
Set-Content -LiteralPath (Join-Path $runner '.runner') -Value '{}'
foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root }
foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root }
$manager = Join-Path $root 'windows-runner-task.ps1'
@'
param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly)
if ($CheckOnly) { exit 0 }
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }'
Add-Content -LiteralPath (Join-Path $RunnerRoot 'consoles.txt') -Value ([ConsoleProbe]::GetConsoleWindow().ToInt64())
Add-Content -LiteralPath (Join-Path $RunnerRoot 'starts.txt') -Value $PID
if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot 'failed-once'))) {
Set-Content -LiteralPath (Join-Path $RunnerRoot 'failed-once') -Value 'yes'
@@ -25,7 +27,7 @@ function Invoke-Manager([string]$Operation, [switch]$ExpectFailure) {
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $manager -Action $Operation -RunnerRoot $runner @installOptions
if (($LASTEXITCODE -eq 0) -eq [bool]$ExpectFailure) { throw "Unexpected exit $LASTEXITCODE for $Operation" }
}
function Wait-Condition([scriptblock]$Condition, [string]$Description, [int]$Seconds = 30) {
function Wait-Condition([scriptblock]$Condition, [string]$Description, [int]$Seconds = 60) {
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
do {
if (& $Condition) { return }
@@ -42,11 +44,37 @@ try {
$installed = $true
$config = Get-Content -LiteralPath (Join-Path $runner 'dsh-task/config.json') -Raw | ConvertFrom-Json
$task = Get-ScheduledTask -TaskName $config.TaskName -TaskPath '\'
$launcher = Join-Path $runner 'dsh-task/launcher.exe'
if ($task.Actions.Execute -ne $launcher) { throw 'Task must start the windowless launcher.' }
$image = [IO.File]::ReadAllBytes($launcher)
$peOffset = [BitConverter]::ToInt32($image, 0x3c)
if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' }
if ($task.State -ne 'Disabled' -or $task.Settings.ExecutionTimeLimit -ne 'PT0S' -or $task.Settings.MultipleInstances -ne 'IgnoreNew') { throw 'Unexpected task scheduling settings.' }
Invoke-Manager install -ExpectFailure
$runScript = Join-Path $runner 'dsh-task/run.ps1'
$originalRun = Get-Content -LiteralPath $runScript -Raw
Set-Content -LiteralPath (Join-Path $runner 'dsh-task/pipe-writer.ps1') -Value 'Start-Sleep -Seconds 240'
$inheritedWriter = @'
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'inherited-writer-started'))) {
$info = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe'))
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
foreach ($arg in @('-NoProfile', '-NonInteractive', '-File', (Join-Path $PSScriptRoot 'pipe-writer.ps1'))) { $info.ArgumentList.Add($arg) }
$writer = [Diagnostics.Process]::Start($info)
Set-Content -LiteralPath (Join-Path $PSScriptRoot 'inherited-writer-started') -Value $writer.Id
$writer.Dispose()
}
'@
Set-Content -LiteralPath $runScript -Value ($inheritedWriter + "`n" + $originalRun)
Invoke-Manager start
$starts = Join-Path $runner 'starts.txt'
Wait-Condition { (Test-Path -LiteralPath $starts) -and @(Get-Content -LiteralPath $starts).Count -ge 2 } 'automatic retry after unexpected clean exit' 110
Wait-Condition { (Get-ScheduledTaskInfo -TaskName $taskName -TaskPath '\').LastTaskResult -eq 5 } 'inherited writer returns drain timeout'
if ((Get-ScheduledTask -TaskName $taskName -TaskPath '\').State -eq 'Disabled') { throw 'Drain timeout disabled automatic recovery.' }
# The one-minute scheduler trigger and cold PowerShell/CIM startup share the busy Windows host.
Wait-Condition { (Test-Path -LiteralPath $starts) -and @(Get-Content -LiteralPath $starts).Count -ge 2 } 'automatic retry after unexpected clean exit' 180
if ([int](Get-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Raw) -ne 2) { throw 'Drain recovery did not consume the normal restart budget.' }
$consoles = @(Get-Content -LiteralPath (Join-Path $runner 'consoles.txt'))
if ($consoles.Count -ne 2 -or @($consoles | Where-Object { $_ -ne '0' }).Count) { throw 'Initial or recovered runner allocated a console.' }
$childId = [int]@(Get-Content -LiteralPath $starts)[-1]
$child = Get-Process -Id $childId
Invoke-Manager start
@@ -79,11 +107,34 @@ try {
Start-ScheduledTask -TaskName $config.TaskName -TaskPath '\'
Wait-Condition { (Get-ScheduledTask -TaskName $config.TaskName -TaskPath '\').State -eq 'Disabled' } 'invalid restart budget disables task'
if (@(Get-Content -LiteralPath $starts).Count -ne 3) { throw 'Invalid budget launched another runner.' }
$savedAction = (Get-ScheduledTask -TaskName $taskName -TaskPath '\').Actions
$missingAction = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + (Join-Path $root 'missing.exe') + '"') -WorkingDirectory $runner
$null = Set-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $missingAction
Set-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Value '0'
$null = Enable-ScheduledTask -TaskName $taskName -TaskPath '\'
Start-ScheduledTask -TaskName $taskName -TaskPath '\'
Wait-Condition { (Get-ScheduledTask -TaskName $taskName -TaskPath '\').State -eq 'Disabled' } 'launcher startup failure disables periodic starts'
Wait-Condition { (Get-ScheduledTaskInfo -TaskName $taskName -TaskPath '\').LastTaskResult -eq 2 } 'launcher startup failure result'
$null = Set-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $savedAction
$taskDirectory = Join-Path $runner 'dsh-task'
$originalAcl = Get-Acl -LiteralPath $taskDirectory
try {
$blockedAcl = Get-Acl -LiteralPath $taskDirectory
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
$blockedAcl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, 'WriteData, AppendData', 'ContainerInherit, ObjectInherit', 'None', 'Deny'))
Set-Acl -LiteralPath $taskDirectory -AclObject $blockedAcl
$null = Enable-ScheduledTask -TaskName $taskName -TaskPath '\'
Start-ScheduledTask -TaskName $taskName -TaskPath '\'
Wait-Condition { (Get-ScheduledTask -TaskName $taskName -TaskPath '\').State -eq 'Disabled' } 'unwritable task directory disables periodic starts'
Wait-Condition { (Get-ScheduledTaskInfo -TaskName $taskName -TaskPath '\').LastTaskResult -eq 3 } 'unwritable diagnostic result'
} finally { Set-Acl -LiteralPath $taskDirectory -AclObject $originalAcl }
if (@(Get-Content -LiteralPath $starts).Count -ne 3) { throw 'Launcher failure started another runner.' }
if ([int](Get-Content -LiteralPath (Join-Path $taskDirectory 'attempts.txt') -Raw) -ne 0) { throw 'Startup failure unexpectedly entered run.ps1.' }
Invoke-Manager uninstall
$installed = $false
Invoke-Manager status -ExpectFailure
if (-not (Test-Path -LiteralPath (Join-Path $runner '.runner'))) { throw 'Registration removed.' }
Write-Output "PASS: install, duplicate refusal, automatic retry, start, status, stop, child teardown, retry exhaustion, uninstall. Evidence: $root"
Write-Output "PASS: windowless initial launch and recovery, install, duplicate refusal, automatic retry, start, status, stop, child teardown, retry exhaustion, startup failure containment, unwritable diagnostics, uninstall. Evidence: $root"
} catch { $primaryFailure = $_; throw } finally {
$cleanupErrors = @()
if ($installed) {
@@ -91,8 +142,8 @@ try {
try { Stop-ScheduledTask -TaskName $taskName -TaskPath '\' } catch { $cleanupErrors += $_ }
try { Unregister-ScheduledTask -TaskName $taskName -TaskPath '\' -Confirm:$false } catch { $cleanupErrors += $_ }
}
foreach ($process in @(Get-CimInstance Win32_Process -Filter "Name = 'pwsh.exe'")) {
if ($process.CommandLine -and $process.CommandLine.Contains($root)) {
foreach ($process in @(Get-CimInstance Win32_Process -Filter "Name = 'pwsh.exe' OR Name = 'launcher.exe'")) {
if (($process.Name -eq 'pwsh.exe' -and $process.CommandLine -and $process.CommandLine.Contains($root)) -or ($process.Name -eq 'launcher.exe' -and $process.ExecutablePath -and $process.ExecutablePath.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase))) {
$owned = Get-Process -Id $process.ProcessId -ErrorAction SilentlyContinue
if ($owned) {
try {
@@ -21,8 +21,8 @@
en: bb889e9514678d1c
zh: 48cbec1e907d6a2b
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup:
en: d32f9f8ce9f5b861
zh: 80d9078a7763f85d
en: d8e2ed7bde76ed7e
zh: 475917ecbd158b48
/windows-signing-and-runner-operations/signing-configuration:
en: 4b5548818375eb9a
zh: 8c975adbb1dca9c8
+5 -3
View File
@@ -36,13 +36,15 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo
### Supervised startup
For the complete registration, label, routing and approval sequence, start with [CI publication setup](../publishing/ci/README.md#setup-sequence). The task installer requires the Windows .NET Framework 4.5+ compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. The output and errors of `run.ps1` itself go to uniquely named `launcher-*.out.log` and `launcher-*.err.log`; filenames include UTC time, PID and a random identifier. Launcher failures disable periodic starts, including failures before the retry budget can be read. Task result `2` indicates launcher failure; `3` means its error log could not be written; `4` means disabling the task also failed and an operator must disable it in Task Scheduler. After the direct child exits, output draining is limited to five seconds. A timeout returns `5` with a `launcher-*.drain.log` diagnostic: output may be incomplete, but periodic recovery continues within the existing restart budget. Descendant processes may outlive the exited runner; draining logs does not terminate them. The recorded stop target is the launcher process, so stop includes the launcher and its descendants. Normal runner output remains in `run-*.log`.
Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token.
The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Logs are retained for operator-managed cleanup.
The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Both `run-*.log` and `launcher-*.log` are retained for operator-managed cleanup.
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. To update installed scripts, stop, uninstall and install again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the configuration directory and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, automatic recovery, duplicate-start prevention, stop, child-process teardown and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. Real-machine acceptance additionally checks an idle runner coming online, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
<a id="signing-configuration"></a>
+5 -3
View File
@@ -36,13 +36,15 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token
### 受监督启动
完整的注册、标签、路由和审批配置顺序见 [CI 发布配置](../publishing/ci/README.zh.md#setup-sequence)。任务安装器需要 Windows .NET Framework 4.5+ 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。`run.ps1` 自身的输出和错误分别写入名称唯一的 `launcher-*.out.log` 和 `launcher-*.err.log`,文件名包含 UTC 时间、PID 和随机标识。启动器失败时禁用周期启动,包括尚未读取重试预算时发生的失败。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志,`4` 表示禁用任务也失败,需要操作者在计划任务中手动禁用。直接子进程退出后,最多等待五秒排空输出;超时返回 `5`,将诊断写入 `launcher-*.drain.log`:输出可能不完整,但周期恢复仍受原有重试预算约束。后代进程可能在 runner 退出后继续运行;日志排空不会终止它们。记录的停止目标是启动器进程,因此 stop 覆盖启动器及其后代。正常 runner 输出仍保存在 `run-*.log`。
以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。
任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。日志保留,由操作者管理清理。
任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。`run-*.log` 和 `launcher-*.log` 均保留,由操作者管理清理。
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。更新已安装脚本时,依次停止、卸载、重新安装。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录配置目录和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、自动恢复、防止重复启动、停止、子进程退出和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。真实机器验收还需检查空闲 runner 上线、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
<a id="signing-configuration"></a>