fix(desktop): preflight local publication config before install

This commit is contained in:
winewill committed 2026-10-16 12:42:29 +08:00
1 parent cf0a621494
commit 012a25f8df
7 files changed
+104 -4

No files matched your search

+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
+2 -2
View File
@@ -24,8 +24,8 @@
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
+1 -1
View File
@@ -72,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>
+1 -1
View File
@@ -72,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)
}
@@ -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'))
}
})