fix(desktop): reject debug publications early and harden runner supervision

This commit is contained in:
winewill committed 2026-10-15 17:40:38 +08:00
1 parent cfa52b0bac
commit 5e23825f15
30 files changed
+254 -35

No files matched your search

+9
View File
@@ -572,6 +572,15 @@ jobs:
- name: Run blocking Windows builds
shell: pwsh
run: pnpm run check:ci:windows-blocking
- 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')) {
$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: Run Windows observational gates on built artifacts
id: observational
continue-on-error: true
+6
View File
@@ -31,6 +31,11 @@ jobs:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Reject debugging packages before dependency installation
id: preflight
env:
GH_TOKEN: ${{ github.token }}
run: node apps/desktop/scripts/preflight-desktop-publish.mjs
- uses: pnpm/action-setup@v5.0.0
- uses: actions/setup-node@v7.0.0
with:
@@ -41,6 +46,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
DESKTOP_PACKAGE_ATTEMPT: ${{ steps.preflight.outputs.attempt }}
run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts
prepare:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
default: test
required: true
skip_notarization:
description: '跳过公证和 stapling:仅限内部调试,不可用于发布或自动更新'
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
default: false
required: true
+1 -1
View File
@@ -10,7 +10,7 @@ on:
default: test
required: true
signing:
description: '签名方式:unsigned 仅供内部测试;signed 使用机器配置的 EV 硬件令牌'
description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)'
type: choice
options: [unsigned, signed]
default: unsigned
+2 -2
View File
@@ -24,8 +24,8 @@
en: f5dc36baf414ecc5
zh: 8984741b356ac1d3
/desktop-packaging/windows-actions-packaging:
en: e3f51e7130a0fe05
zh: 70fd6a40d5899871
en: 6f8f6df680a43ea0
zh: 276a1d60dcd5f573
/desktop-packaging/macos-self-hosted-packaging:
en: a3af9523fdc60036
zh: 5e18d4740e9b7f57
+2
View File
@@ -103,6 +103,8 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
## Windows Actions packaging
For supervised runner startup and recovery, follow [Windows runner machine setup](../windows-signing/README.md#machine-setup).
The `Package Desktop · Windows` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts.
+2
View File
@@ -103,6 +103,8 @@ macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../
## Windows Actions 打包
runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-signing/README.zh.md#machine-setup)。
`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。
+2 -2
View File
@@ -18,8 +18,8 @@
en: 990ff7e56dd6005a
zh: 18f9dc23c520dc8c
/desktop-ci-publication/package-and-publish:
en: 2143e73bc7c65385
zh: cd968050774f4bb3
en: 0a26537845e89dda
zh: 307378bb65e24650
/desktop-ci-publication/recovery-and-records:
en: e92bfad06162443e
zh: 6c3e3d8ece673217
+2
View File
@@ -35,6 +35,8 @@ Environment approval gates this workflow, not arbitrary code running under the s
## Package and publish
For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes.
Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits.
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.
+2
View File
@@ -35,6 +35,8 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
## 打包与发布
发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。
选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。
打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。
@@ -0,0 +1,7 @@
/** Explain why retained debugging packages cannot enter either publication environment. */
/**
* Return rebuilding instructions for debugging packages.
* @param qualification Recorded package qualification.
* @returns Diagnostic, or undefined for a non-debugging qualification.
*/
export function desktopPublishQualificationError(qualification: string): string | undefined
@@ -0,0 +1,15 @@
/** Explain why retained debugging packages cannot enter either publication environment. */
/**
* Return rebuilding instructions for debugging packages.
* @param {string} qualification Recorded package qualification.
* @returns {string | undefined} Diagnostic, or undefined for a non-debugging qualification.
*/
export function desktopPublishQualificationError(qualification) {
const remedy = qualification === 'unsigned'
? '该包未签名(unsigned)。请重新运行 Windows 打包并选择 signing=signed。'
: qualification === 'unnotarized'
? '该包跳过了公证(unnotarized)。请重新运行 macOS 打包,取消勾选“跳过公证”,完成公证和 stapling。'
: undefined
return remedy && `desktop publish: ${remedy}当前产物仅限内部调试,不能发布到 test 或 production,也不能用于自动更新。打包成功后使用新的 run ID / attempt 发布;重跑当前发布任务无效。`
}
@@ -1,5 +1,6 @@
/** Validate a completed packaging attempt and route publication back to its original runner. */
import { z } from 'zod'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
const positive = z.number().int().positive()
const runSchema = z.object({
@@ -12,7 +13,7 @@ const requestSchema = z.object({ source: z.object({
workflowCommit: z.string(), sourceRef: z.string().min(1),
channel: z.enum(['nightly', 'latest']),
runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']),
qualification: z.enum(['signed', 'notarized']),
qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']),
}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef),
'Production source must be master or dsh-v*') })
const jobsSchema = z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable() }))
@@ -45,6 +46,8 @@ export function resolveDesktopPublishRoute(
): DesktopPublishRoute {
const run = runSchema.parse(runValue)
const { source } = requestSchema.parse(requestValue)
const diagnostic = desktopPublishQualificationError(source.qualification)
if (diagnostic) throw new Error(diagnostic)
const jobs = jobsSchema.parse(jobsValue)
const routes = routesSchema.parse(routesValue)
const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml'
@@ -0,0 +1,10 @@
/** Reject identifiable debugging artifacts before installing publication dependencies. */
/**
* Pin the requested attempt and reject its explicitly named debugging artifacts.
* @param repository GitHub repository.
* @param inputs Dispatch inputs.
* @param api Parsed GitHub JSON.
* @returns Exact attempt for subsequent routing; local verification remains required.
*/
export function preflightDesktopPublish(repository: string, inputs: { package_run_id: string; package_run_attempt?: string },
api: (path: string, paginate?: boolean) => unknown): string
@@ -0,0 +1,47 @@
/** Reject identifiable debugging artifacts before installing publication dependencies. */
import { execFileSync } from 'node:child_process'
import { appendFile, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
/**
* Pin the requested attempt and reject its explicitly named debugging artifacts.
* Artifact names only reject; local inventory and signature verification still authorize publication.
* @param {string} repository GitHub repository.
* @param {{ package_run_id: string, package_run_attempt?: string }} inputs Dispatch inputs.
* @param {(path: string, paginate?: boolean) => unknown} api Parsed GitHub JSON; validated here without dependencies.
* @returns {string} Exact attempt for subsequent routing.
*/
export function preflightDesktopPublish(repository, inputs, api) {
if (!/^[\w.-]+\/[\w.-]+$/u.test(repository) || !/^[1-9]\d*$/u.test(inputs.package_run_id)
|| (inputs.package_run_attempt && !/^[1-9]\d*$/u.test(inputs.package_run_attempt))) {
throw new Error('desktop publish: repository, package_run_id and package_run_attempt must identify a valid packaging attempt')
}
const base = `repos/${repository}/actions/runs/${inputs.package_run_id}`
const latest = inputs.package_run_attempt ? undefined : api(base)
const attempt = inputs.package_run_attempt || String(latest && typeof latest === 'object' && 'run_attempt' in latest ? latest.run_attempt : '')
if (!/^[1-9]\d*$/u.test(attempt)) throw new Error('desktop publish: GitHub returned an invalid attempt')
const pages = api(`${base}/artifacts?per_page=100`, true)
if (!Array.isArray(pages) || pages.some(page => !Array.isArray(page.artifacts)
|| page.artifacts.some(artifact => typeof artifact.name !== 'string'))) {
throw new Error('desktop publish: GitHub returned invalid artifact metadata')
}
for (const { name } of pages.flatMap(page => page.artifacts)) {
if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue
const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned'
: name.startsWith('macos-arm64-unnotarized-debug-') ? 'unnotarized' : ''
const diagnostic = desktopPublishQualificationError(qualification)
if (diagnostic) throw new Error(diagnostic)
}
return attempt
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
const env = process.env
if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT) throw new Error('desktop publish: Actions context required')
const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'))
const attempt = preflightDesktopPublish(env.GITHUB_REPOSITORY, event.inputs, (path, paginate) =>
JSON.parse(execFileSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path],
{ encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 })))
await appendFile(env.GITHUB_OUTPUT, `attempt=${attempt}\n`)
}
+4 -1
View File
@@ -14,6 +14,7 @@ import { publishDesktopRelease } from './desktop-publish.ts'
import { desktopPublicationFailure } from './desktop-publication-failure.ts'
import { desktopPublicationSummary } from './desktop-publication-summary.ts'
import { publishDesktopTag } from './desktop-publish-tag.ts'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(),
sourceRef: z.string(), channel: z.enum(['nightly', 'latest']),
@@ -36,7 +37,9 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi
|| source.preset !== route.preset) {
throw new Error('desktop publish: local package differs from the routed request')
}
if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: debugging artifacts cannot be published')
const diagnostic = desktopPublishQualificationError(source.qualification)
if (diagnostic) throw new Error(diagnostic)
if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: package qualification does not match its platform')
const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, source.preset)
const directory = retainedDesktopPath(root, route.runId, route.attempt)
const environment = source.preset === 'test'
@@ -62,6 +62,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
const env = process.env
if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT) throw new Error('desktop publish: Actions context required')
const event = z.object({ inputs: inputsSchema }).parse(JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8')))
const selected = selectDesktopPackage(env.GITHUB_REPOSITORY, event.inputs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}'), desktopPublishApi)
const inputs = { ...event.inputs, package_run_attempt: env.DESKTOP_PACKAGE_ATTEMPT || event.inputs.package_run_attempt }
const selected = selectDesktopPackage(env.GITHUB_REPOSITORY, inputs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}'), desktopPublishApi)
await appendFile(env.GITHUB_OUTPUT, `selection=${JSON.stringify(selected)}\nrunner=${selected.runnerLabel}\n`)
}
+4 -1
View File
@@ -6,6 +6,7 @@ import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDeskt
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts'
import { validateDesktopBuildVersion } from './desktop-build-version.mjs'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]
@@ -66,8 +67,10 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(
}
const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name)))
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`)
const nextStep = desktopPublishQualificationError(qualification)
?? `Publish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}`
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY,
`\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\nPublish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}\n`)
`\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\n${nextStep}\n`)
return retained
}
@@ -1,5 +1,8 @@
# Repeating Task Scheduler triggers recover an exited launcher without relying on action exit-code retries.
$ErrorActionPreference = 'Stop'
$runnerRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)).TrimEnd('\','/')
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($runnerRoot.ToUpperInvariant())))
$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')
@@ -16,9 +19,11 @@ try {
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'start.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log
$code = $LASTEXITCODE
Add-Content -LiteralPath $log -Value "Runner launcher exited with code $code."
# run.cmd maps several listener failures to zero; a persistent task must stay alive until explicitly stopped.
# An unexpected exit is a task failure even when run.cmd hides the listener's nonzero status; the log retains its code.
exit 1
} catch {
# A missing or unwritable counter cannot bound retries; the task name must not depend on readable configuration.
$null = Disable-ScheduledTask -TaskName $taskName -TaskPath '\'
$message = 'Runner task failed: ' + $_.Exception.Message
[Console]::Error.WriteLine($message)
Add-Content -LiteralPath $log -Value $message
+11 -2
View File
@@ -9,6 +9,9 @@ param(
)
$ErrorActionPreference = 'Stop'
if (-not $IsWindows) { throw 'This command requires PowerShell 7 on Windows.' }
if ($Action -ne 'install' -and @('ConfigRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) {
throw 'ConfigRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.'
}
if ($RunnerRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'RunnerRoot must be an absolute local directory.' }
$RunnerRoot = [IO.Path]::GetFullPath($RunnerRoot).TrimEnd('\','/')
if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot '.runner') -PathType Leaf)) { throw 'Register the runner before managing its task.' }
@@ -55,7 +58,9 @@ if (-not $task) { throw 'Task is not installed for this runner directory.' }
switch ($Action) {
'status' {
$info = Get-ScheduledTaskInfo -InputObject $task
[pscustomobject]@{ TaskName = $taskName; State = $task.State; LastRunTime = $info.LastRunTime; LastTaskResult = $info.LastTaskResult; Launches = (Get-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Raw).Trim(); Logs = $serviceDir }
$launches = [int](Get-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Raw)
$config = Get-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Raw | ConvertFrom-Json
[pscustomobject]@{ TaskName = $taskName; State = $task.State; LastRunTime = $info.LastRunTime; LastTaskResult = $info.LastTaskResult; Launches = $launches; RemainingLaunches = [Math]::Max(0, 1 + $config.RestartCount - $launches); Logs = $serviceDir }
}
'start' {
if ($task.State -eq 'Running') { Write-Output "$taskName is already running."; break }
@@ -73,12 +78,16 @@ switch ($Action) {
}
}
$null = Disable-ScheduledTask -InputObject $task
$task = Get-ScheduledTask -TaskName $taskName -TaskPath '\'
if ($task.State -eq 'Running') {
$record = Get-Content -LiteralPath (Join-Path $serviceDir 'process.json') -Raw | ConvertFrom-Json
$recordFile = Join-Path $serviceDir 'process.json'
if (-not (Test-Path -LiteralPath $recordFile)) { throw 'Running task process identity is unavailable. Automatic starts are disabled; inspect the task before stopping it.' }
$record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json
$owner = Get-Process -Id $record.Pid -ErrorAction SilentlyContinue
if (-not $owner -or $owner.StartTime.ToUniversalTime().Ticks -ne $record.Started) {
throw 'Running task process identity is unavailable. Automatic starts are disabled; inspect the task before stopping it.'
}
# Parent IDs can be reused; this snapshot supplies wait handles, not termination authority.
$processes = @(Get-CimInstance Win32_Process)
$ownedIds = [Collections.Generic.HashSet[int]]::new()
$null = $ownedIds.Add($owner.Id)
@@ -0,0 +1,36 @@
import { expect, it } from 'vitest'
import { preflightDesktopPublish } from '../scripts/preflight-desktop-publish.mjs'
it.each([
['macos-arm64-unnotarized-debug-1.0.0-42-2', '跳过了公证'],
['windows-x64-unsigned-1.0.0-42-2', '未签名'],
])('rejects %s from paginated artifact metadata with rebuilding instructions', (name, reason) => {
const paths: string[] = []
expect(() => preflightDesktopPublish('owner/repo', { package_run_id: '42' }, (path, paginate) => {
paths.push(path)
if (!paginate) return { run_attempt: 2 }
return [{ artifacts: [] }, { artifacts: [{ name, expired: true }] }]
})).toThrow(reason)
expect(paths).toEqual(['repos/owner/repo/actions/runs/42', 'repos/owner/repo/actions/runs/42/artifacts?per_page=100'])
})
it.each([{ artifacts: [] }, { artifacts: [{ name: 'windows-x64-signed-1.0.0-42-2' }] }, { artifacts: [{ name: 'windows-x64-unsigned-1.0.0-42-1' }] }])(
'leaves missing metadata and other attempts to authoritative local verification: %j', ({ artifacts }) => {
expect(preflightDesktopPublish('owner/repo', { package_run_id: '42', package_run_attempt: '2' }, (path, paginate) => {
expect(paginate).toBe(true)
expect(path).toContain('/42/artifacts?')
return [{ artifacts }]
})).toBe('2')
},
)
it('rejects malformed input before contacting GitHub', () => {
expect(() => preflightDesktopPublish('owner/repo', { package_run_id: '../42' }, () => {
throw new Error('API must not be called')
})).toThrow(/valid packaging attempt/u)
})
it.each([{ response: {} }, { response: [{ artifacts: [{}] }] }])('rejects invalid artifact responses: %j', ({ response }) => {
expect(() => preflightDesktopPublish('owner/repo', { package_run_id: '42', package_run_attempt: '2' }, () => response))
.toThrow(/invalid artifact metadata/u)
})
@@ -18,6 +18,16 @@ it('routes the exact successful attempt to its registered machine', () => {
.toMatchObject({ runId: '42', attempt: '2', runnerLabel: 'dsh-mini', preset: 'test' })
})
it.each(['test', 'production'])('explains how to rebuild debugging packages for %s', (preset) => {
for (const [qualification, remedy] of [['unsigned', 'signing=signed'], ['unnotarized', '取消勾选“跳过公证”']]) {
const f = fixture()
f.source.preset = preset
f.source.qualification = qualification!
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow(remedy)
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow('重跑当前发布任务无效')
}
})
it.each(['attempt', 'repository', 'commit', 'mode', 'runner', 'workflow'] as const)('rejects mismatched %s evidence', (field) => {
const f = fixture()
if (field === 'attempt') f.source.attempt = '1'
@@ -0,0 +1,4 @@
Retained package: 123/2
desktop publish: 该包跳过了公证(unnotarized)。请重新运行 macOS 打包,取消勾选“跳过公证”,完成公证和 stapling。当前产物仅限内部调试,不能发布到 test 或 production,也不能用于自动更新。打包成功后使用新的 run ID / attempt 发布;重跑当前发布任务无效。
@@ -0,0 +1,4 @@
Retained package: 123/2
desktop publish: 该包未签名(unsigned)。请重新运行 Windows 打包并选择 signing=signed。当前产物仅限内部调试,不能发布到 test 或 production,也不能用于自动更新。打包成功后使用新的 run ID / attempt 发布;重跑当前发布任务无效。
+21 -6
View File
@@ -1,36 +1,51 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { desktopReleasesRoot, retainDesktopCi } from '../scripts/retain-desktop-ci.ts'
import { verifyRetainedDesktopRelease } from '../scripts/desktop-release-store.ts'
import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts'
const roots: string[] = []
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
it.each(['mac-arm64', 'win-x64'] as const)('retains the complete %s release without signing or upload credentials', async (target) => {
it.each([
['mac-arm64', 'notarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'],
] as const)('retains the complete %s %s release without signing or upload credentials', async (target, qualification) => {
const root = await mkdtemp(join(tmpdir(), 'retain-ci-'))
roots.push(root)
const appRoot = join(root, 'checkout')
const config = join(root, 'packaging')
const artifacts = join(appRoot, '.desktop-build', 'targets', target, 'artifacts')
const debug = qualification === 'unsigned' || qualification === 'unnotarized'
const artifacts = join(appRoot, '.desktop-build', 'targets', target, debug ? `${qualification}-artifacts` : 'artifacts')
await mkdir(artifacts, { recursive: true })
await mkdir(join(config, 'presets'), { recursive: true })
await writeFile(join(appRoot, 'package.json'), '{"version":"1.0.0"}')
await writeFile(join(config, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${'a'.repeat(32)}\n`)
const names = target === 'win-x64'
const names = debug ? (target === 'win-x64' ? [`deepseek-harness-1.0.0-win-x64-${qualification}.exe`]
: [`deepseek-harness-1.0.0-mac-arm64-${qualification}.dmg`, `deepseek-harness-1.0.0-mac-arm64-${qualification}.zip`]) : target === 'win-x64'
? ['deepseek-harness-1.0.0-win-x64.exe', 'deepseek-harness-1.0.0-win-x64.exe.blockmap', 'win-x64-release.json', 'nightly.yml']
: ['deepseek-harness-1.0.0-mac-arm64.dmg', 'deepseek-harness-1.0.0-mac-arm64.zip', 'deepseek-harness-1.0.0-mac-arm64.zip.blockmap', 'mac-arm64-release.json', 'nightly-mac.yml']
await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture')))
const env = {
DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin',
DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0',
PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2',
PACKAGE_SIGNING: qualification === 'unsigned' ? 'unsigned' : 'signed', PACKAGE_SKIP_NOTARIZATION: qualification === 'unnotarized' ? 'true' : 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2',
GITHUB_STEP_SUMMARY: join(root, 'summary.md'),
GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated',
}
await retainDesktopCi(env, appRoot)
const manifest = await verifyRetainedDesktopRelease(desktopReleasesRoot(env), '123', '2')
expect(manifest.files.map(file => file.name)).toEqual(names)
expect(manifest.source).toMatchObject({ target, qualification: target === 'win-x64' ? 'signed' : 'notarized' })
expect(manifest.source).toMatchObject({ target, qualification })
expect(manifest.source.publishRequested).toBeUndefined()
const summary = await readFile(env.GITHUB_STEP_SUMMARY, 'utf8')
if (debug) {
expect(summary).toContain('不能发布到 test 或 production')
expect(summary).not.toContain('Publish separately')
await expect(prepareDesktopPublication({ ...env, DESKTOP_PUBLISH_ROUTE: JSON.stringify({
runId: '123', attempt: '2', commit: env.GITHUB_SHA, runnerName: env.RUNNER_NAME, target, preset: 'test', sourceRef: 'master', channel: 'nightly',
}) })).rejects.toThrow('重跑当前发布任务无效')
await expect(summary).toMatchFileSnapshot(`./expected/retained-${qualification}.md`)
} else expect(summary).toContain('Publish separately with package_run_id=123 and package_run_attempt=2')
})
+25 -10
View File
@@ -21,7 +21,8 @@ Write-Output 'FAKE RUNNER READY'
while ($true) { Start-Sleep -Seconds 1 }
'@ | Set-Content -LiteralPath (Join-Path $root 'start-windows-packaging-runner.ps1')
function Invoke-Manager([string]$Operation, [switch]$ExpectFailure) {
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $manager -Action $Operation -RunnerRoot $runner -ConfigRoot $root -RestartCount 2
$installOptions = if ($Operation -eq 'install') { @('-ConfigRoot', $root, '-RestartCount', '2') } else { @() }
& (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) {
@@ -33,6 +34,9 @@ function Wait-Condition([scriptblock]$Condition, [string]$Description, [int]$Sec
throw "Timed out: $Description"
}
$installed = $false
$primaryFailure = $null
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($runner.ToUpperInvariant())))
$taskName = 'DSH-Packaging-' + $digest.Substring(0,16)
try {
Invoke-Manager install
$installed = $true
@@ -53,10 +57,11 @@ try {
$originalRecord = Get-Content -LiteralPath $recordFile -Raw
$record = $originalRecord | ConvertFrom-Json
$record.Started = 0
$record | ConvertTo-Json | Set-Content -LiteralPath $recordFile
Invoke-Manager stop -ExpectFailure
if ($child.HasExited) { throw 'Stale process record terminated a process.' }
Set-Content -LiteralPath $recordFile -Value $originalRecord
try {
$record | ConvertTo-Json | Set-Content -LiteralPath $recordFile
Invoke-Manager stop -ExpectFailure
if ($child.HasExited) { throw 'Stale process record terminated a process.' }
} finally { Set-Content -LiteralPath $recordFile -Value $originalRecord }
Invoke-Manager stop
Wait-Condition { $child.Refresh(); $child.HasExited } 'scheduler child termination'
Invoke-Manager start
@@ -69,25 +74,35 @@ try {
Start-ScheduledTask -TaskName $config.TaskName -TaskPath '\'
Wait-Condition { (Get-ScheduledTask -TaskName $config.TaskName -TaskPath '\').State -eq 'Disabled' } 'exhausted restart budget disables task'
if (@(Get-Content -LiteralPath $starts).Count -ne 3) { throw 'Exhausted budget launched another runner.' }
Set-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Value 'invalid'
$null = Enable-ScheduledTask -TaskName $config.TaskName -TaskPath '\'
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.' }
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"
} finally {
} catch { $primaryFailure = $_; throw } finally {
$cleanupErrors = @()
if ($installed) {
Invoke-Manager stop
Invoke-Manager uninstall
try { $null = Disable-ScheduledTask -TaskName $taskName -TaskPath '\' } catch { $cleanupErrors += $_ }
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)) {
$owned = Get-Process -Id $process.ProcessId -ErrorAction SilentlyContinue
if ($owned) {
Stop-Process -InputObject $owned
if (-not $owned.WaitForExit(30000)) { throw 'Fixture process did not exit.' }
try {
Stop-Process -InputObject $owned
if (-not $owned.WaitForExit(30000)) { throw 'Fixture process did not exit.' }
} catch { $cleanupErrors += $_ }
}
}
}
# Logs stay available for diagnosing scheduler failures; the fixture owns no registered runner credentials.
Write-Output "Fixture retained: $root"
if ($cleanupErrors.Count) { Write-Warning ($cleanupErrors -join "`n"); if (-not $primaryFailure) { throw 'Fixture cleanup failed.' } }
}
@@ -21,8 +21,8 @@
en: bb889e9514678d1c
zh: 48cbec1e907d6a2b
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup:
en: 45ed84267ca7bdf7
zh: 6750dc58cce42023
en: d32f9f8ce9f5b861
zh: 80d9078a7763f85d
/windows-signing-and-runner-operations/signing-configuration:
en: 4b5548818375eb9a
zh: 8c975adbb1dca9c8
+2 -2
View File
@@ -36,9 +36,9 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo
### Supervised startup
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. Run the same command with `-Action start` or `-Action status`; only installation needs `-ConfigRoot`. 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.
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; starting an already running task does not reset it. 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. Logs 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.
+2 -2
View File
@@ -36,9 +36,9 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token
### 受监督启动
以 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`;只有安装需要 `-ConfigRoot`。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。
以 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`),直到再次显式启动;对正在运行的任务执行 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 确认任务分配。日志保留,由操作者管理清理。
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。更新已安装脚本时,依次停止、卸载、重新安装。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
+9
View File
@@ -18,6 +18,15 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github
}>
}
it('rejects debugging packages before dependency setup and pins the checked attempt for routing', () => {
const steps = workflow.jobs.route!.steps
const preflight = steps.findIndex(step => step.id === 'preflight')
expect(preflight).toBeGreaterThan(0)
expect(preflight).toBeLessThan(steps.findIndex(step => step.uses?.startsWith('pnpm/')))
expect(steps[preflight]?.run).toBe('node apps/desktop/scripts/preflight-desktop-publish.mjs')
expect(steps.find(step => step.id === 'route')?.env?.DESKTOP_PACKAGE_ATTEMPT).toBe('${{ steps.preflight.outputs.attempt }}')
})
it('selects packages manually and gates production secrets with the production Environment', () => {
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: {