fix(release): verify helper CLI execution on clean checkouts

This commit is contained in:
creatixchu
2026-11-03 17:29:37 +08:00
parent 41beadfa2a
commit 681cf973e2
9 changed files with 34 additions and 13 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ jobs:
CUA_EXPECTED_BUILD_KEY: ${{ steps.build-inputs.outputs.build-key }}
run: |
mkdir -p dist
pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper
node --import tsx/esm scripts/cua-helper/package.ts --out dist/cua-helper
- uses: actions/upload-artifact@v7.0.1
if: steps.retained.outputs.artifact-id == ''
id: upload
@@ -15,8 +15,8 @@
en: e8cbc43d38786c40
zh: 52b3ba85d98011b9
/dsh-cua-native-helper/build-and-service:
en: 98890b89da811dcb
zh: 9066aee7da4a5f31
en: bbc9d596d2b4bd20
zh: 3b13695a23818237
/dsh-cua-native-helper/request-protocol:
en: fc8043c425322cdd
zh: cf1edb9a78fda301
@@ -37,7 +37,7 @@ Certificate signatures include a signing time. Development builds record the gen
Generations in `native/.build/prepared/<arch>` and `native/bin/darwin-<arch>` are retained without automatic garbage collection. Use a fresh `--output-directory` for release staging, or clean the packaging output before building while no client uses it, so old generations do not enter the tarball. Secure signing timestamps can create distinct generations for identical sources.
The manual **Package CUA helper · macOS** [workflow](../../../../.github/workflows/cua-helper-package.yml) builds both architectures on `dsh-mac-package-trial`. For manual runs, choose a trusted branch or tag with **Use workflow from** and select the machine `preset`. Checkout uses the commit recorded for that Actions run; publication calls use the caller run's commit. Manual runs always build new artifacts. Publication reuse requires GitHub CLI (`gh`) on the runner's `PATH`; credentials use the existing [macOS packaging configuration](../../../../apps/desktop/packaging/README.md#macos-actions). Packaging does not require a macOS desktop login or Rosetta. `pnpm exec tsx scripts/cua-helper/package.ts --out <new-directory>` runs the same preparation from the repository root. It signs, notarizes and staples each complete application before selecting its final content digest, then verifies architectures, signatures, tickets, Gatekeeper and file integrity from the extracted `cua-helper.tgz`. Packaging does not launch the helper. Run `python3 scripts/cua-helper/smoke.py <helper-executable>` separately in a logged-in desktop session to check capabilities, helper identity and cleanup; x64 execution on Apple Silicon requires Rosetta. The smoke does not request permissions or inspect applications. Failed or timed-out commands and unreadable startup replies report captured stdout and stderr; invalid encoding is replaced only in diagnostic text, while protocol parsing remains strict. A successful package build does not certify runtime behavior. Helper packaging retains its own [diagnostic record](../../../../apps/desktop/packaging/README.md#macos-release-signing), including failures before desktop installer startup.
The manual **Package CUA helper · macOS** [workflow](../../../../.github/workflows/cua-helper-package.yml) builds both architectures on `dsh-mac-package-trial`. For manual runs, choose a trusted branch or tag with **Use workflow from** and select the machine `preset`. Checkout uses the commit recorded for that Actions run; publication calls use the caller run's commit. Manual runs always build new artifacts. Publication reuse requires GitHub CLI (`gh`) on the runner's `PATH`; credentials use the existing [macOS packaging configuration](../../../../apps/desktop/packaging/README.md#macos-actions). Packaging does not require a macOS desktop login or Rosetta. `node --import tsx/esm scripts/cua-helper/package.ts --out <new-directory>` runs the same preparation from the repository root. It signs, notarizes and staples each complete application before selecting its final content digest, then verifies architectures, signatures, tickets, Gatekeeper and file integrity from the extracted `cua-helper.tgz`. Packaging does not launch the helper. Run `python3 scripts/cua-helper/smoke.py <helper-executable>` separately in a logged-in desktop session to check capabilities, helper identity and cleanup; x64 execution on Apple Silicon requires Rosetta. The smoke does not request permissions or inspect applications. Failed or timed-out commands and unreadable startup replies report captured stdout and stderr; invalid encoding is replaced only in diagnostic text, while protocol parsing remains strict. A successful package build does not certify runtime behavior. Helper packaging retains its own [diagnostic record](../../../../apps/desktop/packaging/README.md#macos-release-signing), including failures before desktop installer startup.
The workflow retains `cua-helper.tgz` and `cua-helper.json`. The manifest records the original build's product version, commit and Team ID, both application digests, the source digest and the build key. [Release publish](../../../../.github/workflows/release-publish.yml) always calls this workflow, then downloads its selected run ID and artifact ID before packing the provider for npm. Staging verifies the current helper source digest and selected build key, both architectures, application generations, file bytes and executable permissions. npm publication rechecks the exact provider tarball before any registry write. Reuse preserves the original manifest and signed bytes across unrelated commits or product versions. Ordinary PR release rehearsals have no signing credentials.
@@ -37,7 +37,7 @@ kind: "package-reference"
`native/.build/prepared/<arch>` 和 `native/bin/darwin-<arch>` 中的各代应用不会自动清理。正式打包时使用全新的 `--output-directory`,或确认没有客户端使用后清理打包输出再构建,避免旧应用进入 tarball。安全签名时间戳可能让相同源码生成不同代应用。
手动工作流 **Package CUA helper · macOS** 的[配置](../../../../.github/workflows/cua-helper-package.yml)在 `dsh-mac-package-trial` 构建两种架构。手动启动时,在 **Use workflow from** 中选择可信的分支或 tag,再选择机器 `preset`。checkout 使用本次 Actions 运行记录的提交;由发布工作流调用时使用调用方本次运行的提交。手动运行始终构建新产物。发布复用要求 runner 的 `PATH` 中有 GitHub CLI(`gh`);凭据沿用[macOS 打包配置](../../../../apps/desktop/packaging/README.zh.md#macos-actions)。打包无需 macOS 桌面登录或 Rosetta。在仓库根目录运行 `pnpm exec tsx scripts/cua-helper/package.ts --out <new-directory>` 可执行相同准备。完整应用签名、公证和贴票后才确定最终内容摘要,再从解包后的 `cua-helper.tgz` 验证架构、签名、票据、Gatekeeper 和文件完整性。打包不启动 helper。在已登录的桌面会话中单独运行 `python3 scripts/cua-helper/smoke.py <helper-executable>` 检查能力、helper 身份和退出清理;Apple Silicon 上执行 x64 版本需要 Rosetta。该检查不申请权限或观察应用。命令失败、超时或启动回复无法解析时,会输出捕获的 stdout 和 stderr;无效编码只在诊断文本中替换,协议解析仍严格校验。打包成功不代表运行验收通过。 helper 打包保留独立的[诊断记录](../../../../apps/desktop/packaging/README.zh.md#macos-release-signing),包括桌面安装包开始构建前发生的失败。
手动工作流 **Package CUA helper · macOS** 的[配置](../../../../.github/workflows/cua-helper-package.yml)在 `dsh-mac-package-trial` 构建两种架构。手动启动时,在 **Use workflow from** 中选择可信的分支或 tag,再选择机器 `preset`。checkout 使用本次 Actions 运行记录的提交;由发布工作流调用时使用调用方本次运行的提交。手动运行始终构建新产物。发布复用要求 runner 的 `PATH` 中有 GitHub CLI(`gh`);凭据沿用[macOS 打包配置](../../../../apps/desktop/packaging/README.zh.md#macos-actions)。打包无需 macOS 桌面登录或 Rosetta。在仓库根目录运行 `node --import tsx/esm scripts/cua-helper/package.ts --out <new-directory>` 可执行相同准备。完整应用签名、公证和贴票后才确定最终内容摘要,再从解包后的 `cua-helper.tgz` 验证架构、签名、票据、Gatekeeper 和文件完整性。打包不启动 helper。在已登录的桌面会话中单独运行 `python3 scripts/cua-helper/smoke.py <helper-executable>` 检查能力、helper 身份和退出清理;Apple Silicon 上执行 x64 版本需要 Rosetta。该检查不申请权限或观察应用。命令失败、超时或启动回复无法解析时,会输出捕获的 stdout 和 stderr;无效编码只在诊断文本中替换,协议解析仍严格校验。打包成功不代表运行验收通过。 helper 打包保留独立的[诊断记录](../../../../apps/desktop/packaging/README.zh.md#macos-release-signing),包括桌面安装包开始构建前发生的失败。
工作流保留 `cua-helper.tgz` 和 `cua-helper.json`。清单记录原始构建的产品版本、commit、Team ID、两份应用摘要、源码摘要和构建摘要。[Release publish](../../../../.github/workflows/release-publish.yml)始终调用该工作流,再按返回的 run ID 和 artifact ID 下载所选产物,生成 provider 的 npm 包。准备步骤校验当前 helper 的源码摘要、选定的构建摘要、两种架构、应用目录、文件字节和执行权限。npm 发布在写入注册表前重新检查实际 provider tarball。无关 commit 或产品版本变化时,复用保留原始清单和已签名字节。普通 PR 发布检查不使用签名凭据。
+14 -2
View File
@@ -114,7 +114,7 @@ it('resolves the configured signing file without installing desktop dependencies
it('loads fingerprinting and selection under plain Node with no node_modules directory', () => {
const f = fixture()
for (const file of ['scripts/cua-helper/build-inputs.ts', 'scripts/cua-helper/select-artifact.ts',
'apps/desktop/scripts/desktop-file-settings.mjs', 'apps/desktop/scripts/desktop-release-environment.mjs']) {
'apps/desktop/scripts/desktop-file-settings.mjs', 'apps/desktop/scripts/desktop-release-environment.mjs', 'scripts/release/process.ts']) {
mkdirSync(dirname(join(f.root, file)), { recursive: true })
copyFileSync(resolve(file), join(f.root, file))
}
@@ -219,7 +219,7 @@ it.skipIf(process.platform === 'win32').each(['match', 'miss', 'api failure', 'm
'runs the plain-Node selector entry with %s and no node_modules directory', (scenario) => {
const f = fixture()
for (const file of ['scripts/cua-helper/build-inputs.ts', 'scripts/cua-helper/select-artifact.ts',
'apps/desktop/scripts/desktop-file-settings.mjs', 'apps/desktop/scripts/desktop-release-environment.mjs']) {
'apps/desktop/scripts/desktop-file-settings.mjs', 'apps/desktop/scripts/desktop-release-environment.mjs', 'scripts/release/process.ts']) {
mkdirSync(dirname(join(f.root, file)), { recursive: true })
copyFileSync(resolve(file), join(f.root, file))
}
@@ -269,3 +269,15 @@ else if(path in data) console.log(JSON.stringify(data[path])); else {console.err
}
},
)
it('matches Git blobs when tracked attributes require CRLF checkout bytes', () => {
const f = fixture()
const path = join(f.root, 'apps/desktop/scripts/fixture.cmd')
writeFileSync(join(f.root, '.gitattributes'), '*.cmd text eol=crlf\n')
writeFileSync(path, '@echo off\necho fixture\n')
const previous = producerSnapshot(f.root)
writeFileSync(path, '@echo off\r\necho fixture\r\n')
expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
writeFileSync(join(f.root, '.gitattributes'), '*.cmd text eol=lf\n')
expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
})
+8 -4
View File
@@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { appendFileSync, lstatSync, readFileSync } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import { isEntry } from '../release/process.ts'
import { readDesktopPackageSettings } from '../../apps/desktop/scripts/desktop-file-settings.mjs'
import { resolveMacOSSigningEnvironment } from '../../apps/desktop/scripts/desktop-release-environment.mjs'
@@ -12,18 +13,18 @@ const sourceDirectories = [
]
const sourceFiles = new Set([
'.github/workflows/cua-helper-package.yml', '.github/workflows/release-publish.yml',
'package.json', 'scripts/package.json', 'apps/package.json', 'apps/desktop/package.json',
'package.json', 'scripts/package.json', 'apps/package.json', 'apps/desktop/package.json', 'scripts/release/process.ts',
'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs',
])
function isSourceFile(path: string): boolean {
return sourceFiles.has(path) || /^(?:(?:apps|apps\/desktop|scripts)\/)?tsconfig[^/]*\.json$/u.test(path)
return sourceFiles.has(path) || path === '.gitattributes' || path.endsWith('/.gitattributes') || /^(?:(?:apps|apps\/desktop|scripts)\/)?tsconfig[^/]*\.json$/u.test(path)
|| path.split('/').includes('node_modules')
|| sourceDirectories.some(directory => path.startsWith(`${directory}/`))
}
function trackedSources(root: string): string[] {
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
.split('\0').filter(isSourceFile).sort()
for (const required of ['packages/experimental/cua-macos/native/Package.swift', 'scripts/cua-helper/package.ts',
'package.json', 'pnpm-lock.yaml']) {
@@ -101,6 +102,9 @@ export function cuaHelperSourceMatches(root: string, repository: string, commit:
const local = sourceFile(root, file)
if (remote.mode !== (local.executable ? '100755' : '100644')) return false
if (gitBlobHash(local.bytes) === remote.sha) continue
const canonical = execFileSync('git', ['hash-object', `--path=${file}`, '--stdin'],
{ cwd: root, input: local.bytes, encoding: 'utf8' }).trim()
if (canonical === remote.sha) continue
if (file !== 'package.json' && !file.endsWith('/package.json')) return false
const blob = api(`repos/${repository}/git/blobs/${remote.sha}`)
assert(blob && typeof blob === 'object' && 'sha' in blob && blob.sha === remote.sha
@@ -153,7 +157,7 @@ export function cuaHelperBuildKey(source: string, environment: NodeJS.ProcessEnv
return createHash('sha256').update(JSON.stringify(configuration)).digest('hex')
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
if (isEntry(import.meta.url)) {
const env = process.env
if (!env.GITHUB_OUTPUT || !env.GITHUB_STEP_SUMMARY) throw new Error('CUA build inputs: Actions context required')
const root = resolve(import.meta.dirname, '../..')
+2 -1
View File
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { isEntry } from '../release/process.ts'
import { parseArgs } from 'node:util'
import { execa } from 'execa'
import { create, extract } from 'tar'
@@ -104,4 +105,4 @@ async function main(): Promise<void> {
journal.finish(true)
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) await main()
if (isEntry(import.meta.url)) await main()
+2 -1
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { appendFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { isEntry } from '../release/process.ts'
import { cuaHelperSourceMatches } from './build-inputs.ts'
const workflows = ['cua-helper-package.yml', 'release-publish.yml'] as const
@@ -117,7 +118,7 @@ function githubJson(path: string, paginate = false): unknown {
return JSON.parse(output)
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
if (isEntry(import.meta.url)) {
const env = process.env
if (!env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT || !env.GITHUB_STEP_SUMMARY || !env.CUA_BUILD_KEY) {
throw new Error('CUA reuse: Actions context and CUA_BUILD_KEY are required')
@@ -233,6 +233,9 @@ it('skips installation and signing on a helper match and exports the original pr
expect(expensive).toHaveLength(4)
expect(job.steps.find(step => step.name === 'Install locked dependencies')?.run)
.toBe('pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile')
const build = job.steps.find(step => step.name === 'Build and verify notarized applications')!
expect(build.run).toContain('node --import tsx/esm scripts/cua-helper/package.ts --out dist/cua-helper')
expect(build.run).not.toContain('pnpm exec')
for (const step of expensive) {
expect(job.steps.indexOf(select)).toBeLessThan(job.steps.indexOf(step))
expect(evaluate(step.if!, { 'steps.retained.outputs.artifact-id': '456' })).toBe(false)