From 681cf973e2f52bcb0d8ae8f65e376527194da8d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 3 Nov 2026 17:29:37 +0800 Subject: [PATCH] fix(release): verify helper CLI execution on clean checkouts --- .github/workflows/cua-helper-package.yml | 2 +- .../cua-macos/native/README.i18n.yaml | 4 ++-- packages/experimental/cua-macos/native/README.md | 2 +- .../experimental/cua-macos/native/README.zh.md | 2 +- scripts/cua-helper/build-inputs.spec.ts | 16 ++++++++++++++-- scripts/cua-helper/build-inputs.ts | 12 ++++++++---- scripts/cua-helper/package.ts | 3 ++- scripts/cua-helper/select-artifact.ts | 3 ++- scripts/tests/ci-release-selfhosted.spec.ts | 3 +++ 9 files changed, 34 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cua-helper-package.yml b/.github/workflows/cua-helper-package.yml index 96b21452da..650138cd37 100644 --- a/.github/workflows/cua-helper-package.yml +++ b/.github/workflows/cua-helper-package.yml @@ -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 diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 4093d1244a..5aeaee1f2f 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -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 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index 20aceab89d..1afafd5467 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -37,7 +37,7 @@ Certificate signatures include a signing time. Development builds record the gen Generations in `native/.build/prepared/` and `native/bin/darwin-` 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 ` 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 ` 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 ` 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 ` 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. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 1c963caaa1..6685bd3798 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" `native/.build/prepared/` 和 `native/bin/darwin-` 中的各代应用不会自动清理。正式打包时使用全新的 `--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 ` 可执行相同准备。完整应用签名、公证和贴票后才确定最终内容摘要,再从解包后的 `cua-helper.tgz` 验证架构、签名、票据、Gatekeeper 和文件完整性。打包不启动 helper。在已登录的桌面会话中单独运行 `python3 scripts/cua-helper/smoke.py ` 检查能力、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 ` 可执行相同准备。完整应用签名、公证和贴票后才确定最终内容摘要,再从解包后的 `cua-helper.tgz` 验证架构、签名、票据、Gatekeeper 和文件完整性。打包不启动 helper。在已登录的桌面会话中单独运行 `python3 scripts/cua-helper/smoke.py ` 检查能力、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 发布检查不使用签名凭据。 diff --git a/scripts/cua-helper/build-inputs.spec.ts b/scripts/cua-helper/build-inputs.spec.ts index a6e544b148..ce126e3433 100644 --- a/scripts/cua-helper/build-inputs.spec.ts +++ b/scripts/cua-helper/build-inputs.spec.ts @@ -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) +}) diff --git a/scripts/cua-helper/build-inputs.ts b/scripts/cua-helper/build-inputs.ts index ae4cebcd34..a5f6b615f1 100644 --- a/scripts/cua-helper/build-inputs.ts +++ b/scripts/cua-helper/build-inputs.ts @@ -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, '../..') diff --git a/scripts/cua-helper/package.ts b/scripts/cua-helper/package.ts index 81d7616a5f..535285ae45 100644 --- a/scripts/cua-helper/package.ts +++ b/scripts/cua-helper/package.ts @@ -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 { journal.finish(true) } -if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) await main() +if (isEntry(import.meta.url)) await main() diff --git a/scripts/cua-helper/select-artifact.ts b/scripts/cua-helper/select-artifact.ts index 6a666b83d7..c1c51273b3 100644 --- a/scripts/cua-helper/select-artifact.ts +++ b/scripts/cua-helper/select-artifact.ts @@ -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') diff --git a/scripts/tests/ci-release-selfhosted.spec.ts b/scripts/tests/ci-release-selfhosted.spec.ts index a65bbb093e..3495a8f04e 100644 --- a/scripts/tests/ci-release-selfhosted.spec.ts +++ b/scripts/tests/ci-release-selfhosted.spec.ts @@ -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)