diff --git a/.github/workflows/cua-helper-package.yml b/.github/workflows/cua-helper-package.yml index c9f2547d5c..96b21452da 100644 --- a/.github/workflows/cua-helper-package.yml +++ b/.github/workflows/cua-helper-package.yml @@ -84,7 +84,7 @@ jobs: dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }} - name: Install locked dependencies if: steps.retained.outputs.artifact-id == '' - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile - name: Build and verify notarized applications if: steps.retained.outputs.artifact-id == '' env: diff --git a/apps/desktop/scripts/desktop-file-settings.d.mts b/apps/desktop/scripts/desktop-file-settings.d.mts index afecbcb87c..5b2597341c 100644 --- a/apps/desktop/scripts/desktop-file-settings.d.mts +++ b/apps/desktop/scripts/desktop-file-settings.d.mts @@ -2,7 +2,7 @@ /** * Read an explicitly selected dotenv without exposing credential-bearing parser errors. - * @param path - Selected file; relative credential paths resolve beside it. + * @param path - Selected file; relative file settings resolve beside it. * @param accepts - Whether this file may declare a setting. * @returns Validated file settings. */ diff --git a/apps/desktop/scripts/desktop-file-settings.mjs b/apps/desktop/scripts/desktop-file-settings.mjs index 2385743229..8c48f9e3f0 100644 --- a/apps/desktop/scripts/desktop-file-settings.mjs +++ b/apps/desktop/scripts/desktop-file-settings.mjs @@ -9,7 +9,7 @@ const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGN * Read an explicitly selected dotenv without exposing credential-bearing parser errors. * @param {string} path Selected file; relative file settings resolve beside it. * @param {(name: string) => boolean} accepts Whether this file may declare a setting. - * @returns {NodeJS.ProcessEnv} Validated file settings. + * @returns {Record} Validated file settings. */ export function readDesktopPackageSettings(path, accepts) { let contents diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 44a773bbac..83e9dbbc0f 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -100,7 +100,6 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en } } - function requireReadableFile(environment, name) { try { if (!statSync(environment[name]).isFile()) throw new Error('not a file') diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 6714cbdad8..4093d1244a 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: fbe2a4a9bc5e4e37 - zh: 40d21b7872e46db0 + en: 98890b89da811dcb + zh: 9066aee7da4a5f31 /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 45acd5e694..20aceab89d 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -41,9 +41,9 @@ The manual **Package CUA helper · macOS** [workflow](../../../../.github/workfl 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. -**Release publish (dsh)** accepts an optional `cua_run_id`: enter the numeric run ID after `/actions/runs/` in the producer's GitHub URL, not its job ID. The publisher enables `reuse` and passes this selection through the helper's `workflow_call` inputs; neither reuse input appears on the manual helper form. Leave `cua_run_id` empty for automatic discovery, which searches completed runs of these two workflows from the last 14 days for an unexpired matching artifact. Selection runs on the Mac before pnpm setup or dependency installation. A match skips installation, compilation, signing and notarization; an automatic miss rebuilds. An explicit run selects its latest attempt once and fails on unsuccessful helper packaging, missing or expired artifacts, or mismatched inputs. It never chooses an older attempt or another run. A later npm failure does not invalidate a successful helper job. GitHub API failures stop selection. The summary links the actual producer; a run that only reuses an artifact does not upload another copy, so use its linked producer run for manual selection. Artifacts are retained for 14 days. +**Release publish (dsh)** accepts an optional `cua_run_id`: enter the numeric run ID after `/actions/runs/` in the producer's GitHub URL, not its job ID. The publisher enables `reuse` and passes this selection through the helper's `workflow_call` inputs; neither reuse input appears on the manual helper form. Leave `cua_run_id` empty for automatic discovery, which searches completed runs of these two workflows created within the last 14 days for an unexpired matching artifact. Selection runs on the Mac before pnpm setup or dependency installation. A match skips installation, compilation, signing and notarization; an automatic miss rebuilds. An explicit run pins its latest attempt and selects its last executed helper job across the run history. A pack/publish-only rerun keeps the original helper artifact eligible; a newer failed helper execution blocks reuse of earlier successes. Missing or expired artifacts and mismatched inputs fail without choosing another run. A later npm failure does not invalidate a successful helper job. GitHub API failures stop selection. The summary links the actual producer; a run that only reuses an artifact does not upload another copy, so use its linked producer run for manual selection. Artifacts are retained for 14 days. Expiry or deletion after selection stops the download; it does not silently select or build different bytes. Follow the retry instructions below. -The [source digest](../../../../scripts/cua-helper/build-inputs.ts) includes tracked file paths, raw bytes and executable bits under `native/`, `scripts/cua-helper/` and `apps/desktop/scripts/`, plus the helper workflow, lockfile, workspace configuration, root TypeScript configurations and package-manager version. Product version and unrelated package sources are excluded. The build key additionally includes the preset, Node/Swift/Clang/Python versions, macOS SDK build, signing identity, Team ID and certificate-container digest; passwords are excluded. Missing inputs or unreadable configuration fail. Artifacts without these records require a new build. Source comparison still requires a Mac runner and checkout; matching inputs establish reuse eligibility, while the consuming pack job validates the downloaded application bytes. +The [source digest](../../../../scripts/cua-helper/build-inputs.ts) covers tracked helper sources, resources, CUA/Desktop packaging scripts, both workflows, package and compiler configuration, locked dependencies, pnpm settings and patches. Tracked `node_modules` entries are also compared. Package manifest version fields are excluded; other manifest fields are retained. The build key additionally includes the preset, Node/Swift/Clang/Python versions, macOS SDK build, signing identity, Team ID and certificate-container digest; passwords are excluded. Selection independently compares the producer run's GitHub `head_sha` tree and package blobs with the current checkout, including paths, bytes and executable modes; artifact-supplied hashes cannot establish source equality. Helper dependency installation disables lifecycle scripts and pnpmfile hooks. Missing inputs or unreadable GitHub evidence fail. Artifacts without matching records require a new build. Selection still requires a Mac runner and checkout; the consuming pack job validates the downloaded application bytes. If npm publication fails after uploading some packages, rerun only **Publish to npm** while its `dsh-npm-tarballs` artifact is retained (7 days). It uploads the original tarballs and skips already published identical bytes. Do not rerun all jobs for this retry. For a new dispatch that must repack the same commit and version, use the original producer `cua_run_id` from the pack summary. If original artifacts are unavailable or any rebuilt package differs, stop: an existing npm version cannot be replaced with different content. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 6518ea20f0..1c963caaa1 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -41,9 +41,9 @@ kind: "package-reference" 工作流保留 `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 发布检查不使用签名凭据。 -**Release publish (dsh)** 接受可选的 `cua_run_id`:填写构建运行页面 URL 中 `/actions/runs/` 后的数字,不填 job ID。发布工作流开启 `reuse`,并通过 helper 的 `workflow_call` 参数传入这个选择;这两个复用参数均不出现在 helper 的手动表单中。`cua_run_id` 留空时自动查找,查询这两个工作流最近 14 天内已结束的运行,选择未过期的匹配产物。选择在 Mac 上执行,早于 pnpm 准备和依赖安装。命中时跳过安装、编译、签名和公证,自动查找未命中时重新构建。手动指定 run ID 时选定最新 attempt;helper 打包未成功、产物缺失或过期、输入不匹配时直接失败,不选择更早的 attempt 或其他运行。后续 npm 失败不影响成功的 helper job。GitHub API 读取失败会停止选择。运行摘要链接实际构建者;只复用产物的运行不重新上传副本,手动选择时填写摘要所链接的构建 run ID。产物保留 14 天。 +**Release publish (dsh)** 接受可选的 `cua_run_id`:填写构建运行页面 URL 中 `/actions/runs/` 后的数字,不填 job ID。发布工作流开启 `reuse`,并通过 helper 的 `workflow_call` 参数传入这个选择;这两个复用参数均不出现在 helper 的手动表单中。`cua_run_id` 留空时自动查找,查询这两个工作流最近 14 天创建且已结束的运行,选择未过期的匹配产物。选择在 Mac 上执行,早于 pnpm 准备和依赖安装。命中时跳过安装、编译、签名和公证,自动查找未命中时重新构建。手动指定 run ID 时先固定最新 attempt,再选取该 run 历史中最后一次实际执行的 helper job。仅重跑 pack/publish 时仍可使用原 helper 产物;若更新的 helper 执行失败,则不复用更早的成功产物。产物缺失或过期、输入不匹配时直接失败,不改选其他运行。后续 npm 失败不影响成功的 helper job。GitHub API 读取失败会停止选择。运行摘要链接实际构建者;只复用产物的运行不重新上传副本,手动选择时填写摘要所链接的构建 run ID。产物保留 14 天。选择后过期或被删除会使下载失败,不自动改选或构建不同字节;按下方重试说明处理。 -[源码摘要](../../../../scripts/cua-helper/build-inputs.ts)覆盖 `native/`、`scripts/cua-helper/`、`apps/desktop/scripts/` 下受 Git 跟踪文件的路径、原始字节和执行权限,以及 helper 工作流、锁文件、workspace 配置、根目录 TypeScript 配置和包管理器版本。产品版本及无关包的源码不参与比较。构建摘要还包含 preset、Node/Swift/Clang/Python 版本、macOS SDK build、签名身份、Team ID 和证书容器摘要,不包含密码。输入缺失或配置无法读取时失败。没有这些记录的旧产物需要重新构建。源码比较仍需等待 Mac runner 并检出代码;输入匹配确定可复用性,消费产物的 pack job 再校验下载的应用字节。 +[源码摘要](../../../../scripts/cua-helper/build-inputs.ts)覆盖受跟踪的 helper 源码、资源、CUA/Desktop 打包脚本、两份工作流、包与编译器配置、锁定依赖、pnpm 配置和补丁;受跟踪的 `node_modules` 文件也参与比较。包清单只排除版本字段,其余字段保留。构建摘要还包含 preset、Node/Swift/Clang/Python 版本、macOS SDK build、签名身份、Team ID 和证书容器摘要,不包含密码。选择时使用生产运行的 GitHub `head_sha`,独立比较对应 Git 树与包清单 blob 和当前 checkout 的路径、字节及执行权限,不能仅凭产物自报摘要认定源码一致。helper 依赖安装禁用生命周期脚本和 pnpmfile hooks。输入缺失或 GitHub 证据无法读取时失败。没有匹配记录的旧产物需要重新构建。选择仍需等待 Mac runner 并检出代码,消费产物的 pack job 再校验下载的应用字节。 npm 发布在上传部分包后失败时,只重跑 **Publish to npm**,复用保留期内的 `dsh-npm-tarballs`(7 天)。该 job 上传原 tarball,跳过已发布且字节相同的包。此次重试不要重跑全部 job。若必须新建运行,以同一 commit 和版本重新打包,填写 pack 摘要中的原始构建 `cua_run_id`。原产物不可用或任何重新打包的内容不同时,应停止发布;不能用不同内容替换已有 npm 版本。 diff --git a/scripts/cua-helper/build-inputs.spec.ts b/scripts/cua-helper/build-inputs.spec.ts index 8a28225c8b..a6e544b148 100644 --- a/scripts/cua-helper/build-inputs.spec.ts +++ b/scripts/cua-helper/build-inputs.spec.ts @@ -1,10 +1,11 @@ /** Byte-level source reuse survives unrelated releases and invalidates changed build inputs. */ -import { execFileSync } from 'node:child_process' -import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { execFileSync, spawnSync } from 'node:child_process' +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { expect, it, onTestFinished } from 'vitest' -import { cuaHelperBuildKey, cuaHelperSourceDigest, readCuaSigningSettings } from './build-inputs.ts' +import { loadDesktopPackageEnvironment } from '../../apps/desktop/scripts/desktop-package-environment.mjs' +import { cuaHelperBuildKey, cuaHelperSourceDigest, cuaHelperSourceMatches, readCuaSigningSettings } from './build-inputs.ts' function fixture() { const root = mkdtempSync(join(tmpdir(), 'cua-inputs-')) @@ -19,6 +20,7 @@ function fixture() { 'scripts/cua-helper/package.ts': 'build helper', 'apps/desktop/scripts/notarize-macos.mjs': 'notarize helper', '.github/workflows/cua-helper-package.yml': 'build configuration', + '.github/workflows/release-publish.yml': 'publish configuration', 'packages/client/unrelated/src/index.ts': 'unrelated feature', } for (const [path, bytes] of Object.entries(files)) { @@ -121,3 +123,149 @@ it('loads fingerprinting and selection under plain Node with no node_modules dir { cwd: f.root, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' }) expect(output.trim()).toBe('function function') }) + +const repository = 'deepseek-harness/deepseek-harness' + +function producerSnapshot(root: string) { + const git = (...args: string[]): string => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' }) + git('add', '-A') + git('-c', `core.hooksPath=${join(root, 'no-hooks')}`, '-c', 'commit.gpgsign=false', '-c', 'user.name=Fixture', + '-c', 'user.email=fixture@example.test', 'commit', '--quiet', '-m', 'fixture') + const commit = git('rev-parse', 'HEAD').trim() + const tree = git('ls-tree', '-r', '-z', 'HEAD').split('\0').filter(Boolean).map((entry) => { + const [metadata, path] = entry.split('\t') + const [mode, type, sha] = (metadata ?? '').split(' ') + if (!path || !mode || !type || !sha) throw new Error('Invalid Git fixture tree entry') + return { path, mode, type, sha } + }) + const response = { truncated: false, tree } + const responses: Record = { [`repos/${repository}/git/trees/${commit}?recursive=1`]: response } + for (const entry of tree.filter(entry => entry.path === 'package.json')) { + const bytes = execFileSync('git', ['cat-file', 'blob', entry.sha], { cwd: root, stdio: 'pipe' }) + responses[`repos/${repository}/git/blobs/${entry.sha}`] = { sha: entry.sha, encoding: 'base64', content: bytes.toString('base64') } + } + return { commit, response, responses, api: (path: string): unknown => { + if (!(path in responses)) throw new Error(`Unexpected GitHub fixture request ${path}`) + return responses[path] + } } +} + +it('compares GitHub commit objects independently of artifact claims while allowing a root version bump', () => { + const f = fixture() + const previous = producerSnapshot(f.root) + expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true) + writeFileSync(join(f.root, 'package.json'), JSON.stringify({ version: '2.0.0', packageManager: 'pnpm@11.28.5' })) + writeFileSync(join(f.root, 'packages/client/unrelated/src/index.ts'), 'another unrelated feature') + expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true) + writeFileSync(f.source, 'changed native code') + expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false) +}) + +it.each(['helper workflow', 'publish workflow', 'packaging script', 'manifest lifecycle', 'npm config', 'patch', 'tracked dependency', 'deleted file'])( + 'rejects independent producer source proof after a changed %s', (change) => { + const f = fixture() + const previous = producerSnapshot(f.root) + const digest = cuaHelperSourceDigest(f.root) + if (change === 'helper workflow') writeFileSync(join(f.root, '.github/workflows/cua-helper-package.yml'), 'forged uploader') + if (change === 'publish workflow') writeFileSync(join(f.root, '.github/workflows/release-publish.yml'), 'forged caller') + if (change === 'packaging script') writeFileSync(join(f.root, 'scripts/cua-helper/package.ts'), 'forged metadata') + if (change === 'manifest lifecycle') writeFileSync(join(f.root, 'package.json'), JSON.stringify({ + version: '1.0.0', packageManager: 'pnpm@11.28.5', scripts: { postinstall: 'unexpected code' }, + })) + if (change === 'npm config') writeFileSync(join(f.root, '.npmrc'), 'node-options=--require=./unexpected.cjs') + if (change === 'patch') { + mkdirSync(join(f.root, 'patches')) + writeFileSync(join(f.root, 'patches/dependency.patch'), 'changed package code') + } + if (change === 'tracked dependency') { + mkdirSync(join(f.root, 'apps/desktop/node_modules/unexpected'), { recursive: true }) + writeFileSync(join(f.root, 'apps/desktop/node_modules/unexpected/index.js'), 'unexpected dependency code') + f.git('add', '--force', 'apps/desktop/node_modules') + } + if (change === 'deleted file') rmSync(f.source) + f.git('add', '-A') + expect(cuaHelperSourceDigest(f.root)).not.toBe(digest) + expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false) + }, +) + +it('stops source comparison when GitHub returns an incomplete tree', () => { + const f = fixture() + const previous = producerSnapshot(f.root) + previous.response.truncated = true + expect(() => cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toThrow('incomplete commit tree') +}) + +it.each(['test', 'production'])('computes the same %s build key through selection and packaging configuration readers', (preset) => { + const f = fixture() + const config = join(f.root, 'config') + mkdirSync(join(config, 'presets'), { recursive: true }) + mkdirSync(join(config, 'secrets')) + writeFileSync(join(config, 'presets', `${preset}.env`), `DSH_DESKTOP_AUTO_UPDATE_ENV=${preset}\n`) + writeFileSync(join(config, 'secrets/signing.p12'), 'fixture certificate') + writeFileSync(join(config, 'secrets/macos-signing.env'), [ + 'CSC_LINK=./signing.p12', 'CSC_KEY_PASSWORD=fixture-password', 'DSH_DESKTOP_MACOS_TEAM_ID=ABCDEFGHIJ', + 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY=Fixture (ABCDEFGHIJ)', + ].join('\n')) + const environment = { DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: preset } + const source = cuaHelperSourceDigest(f.root) + const run = (_command: string, args: string[]): string => args.join(' ') + expect(cuaHelperBuildKey(source, readCuaSigningSettings(environment), run)) + .toBe(cuaHelperBuildKey(source, loadDesktopPackageEnvironment('darwin', environment), run)) +}) + +// The fake gh uses a POSIX executable shim; the production workflow runs on macOS. +it.skipIf(process.platform === 'win32').each(['match', 'miss', 'api failure', 'missing gh'])( + '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']) { + mkdirSync(dirname(join(f.root, file)), { recursive: true }) + copyFileSync(resolve(file), join(f.root, file)) + } + const snapshot = producerSnapshot(f.root) + const key = 'a'.repeat(64) + const base = `repos/${repository}/actions/runs/123` + Object.assign(snapshot.responses, { + [base]: { run_attempt: 2 }, + [`${base}/attempts/2`]: { id: 123, run_attempt: 2, status: 'completed', conclusion: 'success', + event: 'workflow_dispatch', path: '.github/workflows/cua-helper-package.yml', head_sha: snapshot.commit, + repository: { full_name: repository }, head_repository: { full_name: repository } }, + [`${base}/jobs?filter=all&per_page=100`]: [{ jobs: [{ name: 'package', conclusion: 'success', run_attempt: 2 }] }], + [`${base}/artifacts?per_page=100`]: [{ artifacts: scenario === 'match' + ? [{ id: 456, name: `cua-helper-123-2-${key}`, expired: false, size_in_bytes: 4096 }] : [] }], + }) + const data = join(f.root, 'responses.json') + writeFileSync(data, JSON.stringify(snapshot.responses)) + const bin = join(f.root, 'fake-bin') + mkdirSync(bin) + if (scenario !== 'missing gh') { + writeFileSync(join(bin, 'gh'), `#!/usr/bin/env node\nconst fs=require('node:fs'); +if(process.env.FIXTURE_API_FAILURE==='1') { console.error('fixture API failure'); process.exit(1); } +const path=process.argv.at(-1); const data=JSON.parse(fs.readFileSync(process.env.FIXTURE_API_DATA,'utf8')); +if(path.includes('/workflows/')) console.log(JSON.stringify([{workflow_runs:path.includes('cua-helper-package.yml')?[{id:123}]:[]}])); +else if(path in data) console.log(JSON.stringify(data[path])); else {console.error('unexpected path '+path);process.exit(1);} +`, { mode: 0o755 }) + } + const output = join(f.root, 'outputs') + const summary = join(f.root, 'summary') + const result = spawnSync(process.execPath, [join(f.root, 'scripts/cua-helper/select-artifact.ts')], { + cwd: f.root, encoding: 'utf8', timeout: 60_000, + env: { ...process.env, NODE_OPTIONS: '', PATH: scenario === 'missing gh' ? bin : `${bin}${delimiter}${process.env.PATH ?? ''}`, + GITHUB_REPOSITORY: repository, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, CUA_RUN_ID: '', CUA_BUILD_KEY: key, + FIXTURE_API_DATA: data, FIXTURE_API_FAILURE: scenario === 'api failure' ? '1' : '0' }, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + if (scenario === 'api failure' || scenario === 'missing gh') { + expect(result.status).toBe(1) + expect(result.stderr).toContain(scenario === 'missing gh' ? 'install GitHub CLI (gh)' : 'check token permissions') + expect(existsSync(output)).toBe(false) + expect(existsSync(summary)).toBe(false) + } else { + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(output, 'utf8')).toBe(scenario === 'match' ? 'artifact-id=456\nrun-id=123\n' : 'artifact-id=\nrun-id=\n') + expect(readFileSync(summary, 'utf8')).toContain(scenario === 'match' ? '跳过依赖安装' : '未找到') + } + }, +) diff --git a/scripts/cua-helper/build-inputs.ts b/scripts/cua-helper/build-inputs.ts index ee18d079be..ae4cebcd34 100644 --- a/scripts/cua-helper/build-inputs.ts +++ b/scripts/cua-helper/build-inputs.ts @@ -1,4 +1,5 @@ /** Content and machine identity for reusing a signed helper before installing dependencies. */ +import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import { appendFileSync, lstatSync, readFileSync } from 'node:fs' @@ -6,37 +7,110 @@ import { isAbsolute, join, resolve } from 'node:path' import { readDesktopPackageSettings } from '../../apps/desktop/scripts/desktop-file-settings.mjs' import { resolveMacOSSigningEnvironment } from '../../apps/desktop/scripts/desktop-release-environment.mjs' -const sourcePaths = [ - 'packages/experimental/cua-macos/native', 'scripts/cua-helper', 'apps/desktop/scripts', - '.github/workflows/cua-helper-package.yml', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', - ':(glob)tsconfig*.json', +const sourceDirectories = [ + 'packages/experimental/cua-macos/native', 'scripts/cua-helper', 'apps/desktop/scripts', 'patches', ] +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', + 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs', +]) -/** - * Hash tracked build inputs by path, raw bytes and executable mode; product version and unrelated packages are excluded. - * Missing tracked files and links fail; build outputs and untracked files never enter the digest. - * @param root - Clean repository checkout containing all helper build inputs. - * @returns SHA-256 of the helper sources, resources, packaging scripts and locked dependencies. - */ -export function cuaHelperSourceDigest(root: string): string { - const files = execFileSync('git', ['ls-files', '-z', '--', ...sourcePaths], { cwd: root, encoding: 'utf8' }) - .split('\0').filter(Boolean).sort() - for (const required of ['packages/experimental/cua-macos/native/Package.swift', 'scripts/cua-helper/package.ts', 'pnpm-lock.yaml']) { +function isSourceFile(path: string): boolean { + return sourceFiles.has(path) || /^(?:(?: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' }) + .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']) { if (!files.includes(required)) throw new Error(`CUA source: missing tracked build input ${required}`) } + return files +} + +function manifestConfiguration(bytes: Buffer, root: boolean): Buffer { + const manifest: unknown = JSON.parse(bytes.toString('utf8')) + assert(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'CUA source: invalid package manifest') + assert(!root || ('packageManager' in manifest && typeof manifest.packageManager === 'string'), + 'CUA source: package.json must declare packageManager') + return Buffer.from(JSON.stringify(Object.fromEntries(Object.entries(manifest).filter(([name]) => name !== 'version')))) +} + +function sourceFile(root: string, file: string): { bytes: Buffer; executable: boolean } { + const path = join(root, file) + const stat = lstatSync(path) + if (!stat.isFile()) throw new Error(`CUA source: build input must be a regular file: ${file}`) + return { bytes: readFileSync(path), executable: (stat.mode & 0o111) !== 0 } +} + +function gitBlobHash(bytes: Buffer): string { + return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex') +} + +/** + * Hash tracked build inputs by path, bytes and executable mode; only package manifest version fields is excluded. + * Missing tracked files and links fail; build outputs and untracked files never enter the digest. + * @param root - Clean repository root containing all helper build inputs. + * @returns SHA-256 of sources, resources, workflows, package configuration, patches and locked dependencies. + */ +export function cuaHelperSourceDigest(root: string): string { const hash = createHash('sha256') + for (const file of trackedSources(root)) { + const { bytes, executable } = sourceFile(root, file) + const input = file === 'package.json' || file.endsWith('/package.json') ? manifestConfiguration(bytes, file === 'package.json') : bytes + hash.update(`${file}\0`).update(createHash('sha256').update(input).digest()).update(Buffer.from([Number(executable)])) + } + return hash.digest('hex') +} + +/** + * Compare a producer's immutable GitHub commit tree with local build inputs independently of artifact metadata. + * Incomplete trees, unreadable blobs and API failures stop selection; content differences return false. + * @param root - Consumer repository root, before dependency installation. + * @param repository - GitHub repository containing the authenticated producer run. + * @param commit - Producer head SHA returned by GitHub, never an artifact-supplied revision. + * @param api - Read-only GitHub JSON reader. + * @returns Whether all selected file paths, bytes and executable modes match, excluding product version fields in package manifests. + */ +export function cuaHelperSourceMatches(root: string, repository: string, commit: string, + api: (path: string) => unknown): boolean { + assert(/^[a-f0-9]{40}$/u.test(commit), 'CUA source: invalid producer commit') + const response = api(`repos/${repository}/git/trees/${commit}?recursive=1`) + assert(response && typeof response === 'object' && 'truncated' in response && response.truncated === false + && 'tree' in response && Array.isArray(response.tree), 'CUA source: GitHub returned an incomplete commit tree') + const tree = new Map() + const entries: unknown[] = response.tree + for (const value of entries) { + assert(value && typeof value === 'object' && 'path' in value && typeof value.path === 'string', 'CUA source: invalid tree entry') + if (!isSourceFile(value.path) || ('type' in value && value.type === 'tree')) continue + assert('sha' in value && typeof value.sha === 'string' && /^[a-f0-9]{40}$/u.test(value.sha) + && 'mode' in value && typeof value.mode === 'string' && 'type' in value, 'CUA source: invalid source entry') + if (value.type !== 'blob' || !['100644', '100755'].includes(value.mode)) return false + assert(!tree.has(value.path), 'CUA source: duplicate source entry') + tree.set(value.path, { sha: value.sha, mode: value.mode }) + } + const files = trackedSources(root) + if (tree.size !== files.length) return false for (const file of files) { - const path = join(root, file) - const stat = lstatSync(path) - if (!stat.isFile()) throw new Error(`CUA source: build input must be a regular file: ${file}`) - hash.update(`${file}\0`).update(createHash('sha256').update(readFileSync(path)).digest()) - .update(Buffer.from([(stat.mode & 0o111) === 0 ? 0 : 1])) + const remote = tree.get(file) + if (!remote) return false + const local = sourceFile(root, file) + if (remote.mode !== (local.executable ? '100755' : '100644')) return false + if (gitBlobHash(local.bytes) === 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 + && 'encoding' in blob && blob.encoding === 'base64' && 'content' in blob && typeof blob.content === 'string', + 'CUA source: invalid package manifest blob') + const bytes = Buffer.from(blob.content, 'base64') + assert(gitBlobHash(bytes) === remote.sha, 'CUA source: package manifest blob digest mismatch') + if (!manifestConfiguration(local.bytes, file === 'package.json').equals(manifestConfiguration(bytes, file === 'package.json'))) return false } - const manifest: unknown = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) - if (!manifest || typeof manifest !== 'object' || !('packageManager' in manifest) || typeof manifest.packageManager !== 'string') { - throw new Error('CUA source: package.json must declare packageManager') - } - return hash.update(`packageManager\0${manifest.packageManager}`).digest('hex') + return true } /** @@ -58,7 +132,7 @@ export function readCuaSigningSettings(environment: NodeJS.ProcessEnv): NodeJS.P * Bind source bytes to the active toolchain, preset and signing certificate container. * @param source - Current source digest. * @param environment - File-owned signing settings including CSC_LINK and the selected preset. - * @param run - Synchronous toolchain query; injected tests never invoke signing or Apple's services. + * @param run - Synchronous query returning the active compiler, SDK and Python version strings. * @returns SHA-256 build key; credential values and certificate bytes are not recorded. */ export function cuaHelperBuildKey(source: string, environment: NodeJS.ProcessEnv, @@ -85,7 +159,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const root = resolve(import.meta.dirname, '../..') const sourceDigest = cuaHelperSourceDigest(root) const buildKey = cuaHelperBuildKey(sourceDigest, readCuaSigningSettings(env)) - appendFileSync(env.GITHUB_OUTPUT, `source-digest=${sourceDigest}\nbuild-key=${buildKey}\n`) + appendFileSync(env.GITHUB_OUTPUT, `build-key=${buildKey}\n`) appendFileSync(env.GITHUB_STEP_SUMMARY, `CUA helper 源码摘要:\`${sourceDigest}\`。\n\n`) console.log(`CUA helper source digest: ${sourceDigest}`) } diff --git a/scripts/cua-helper/package.ts b/scripts/cua-helper/package.ts index 467da7bb2a..81d7616a5f 100644 --- a/scripts/cua-helper/package.ts +++ b/scripts/cua-helper/package.ts @@ -104,4 +104,4 @@ async function main(): Promise { journal.finish(true) } -await main() +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) await main() diff --git a/scripts/cua-helper/select-artifact.spec.ts b/scripts/cua-helper/select-artifact.spec.ts index 640e9bc58c..db8821f960 100644 --- a/scripts/cua-helper/select-artifact.spec.ts +++ b/scripts/cua-helper/select-artifact.spec.ts @@ -8,8 +8,9 @@ const key = 'a'.repeat(64) function fixture() { const run = { id: 123, run_attempt: 2, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', - path: '.github/workflows/cua-helper-package.yml', repository: { full_name: repository }, head_repository: { full_name: repository } } - const jobs = [{ jobs: [{ name: 'package', conclusion: 'success' }] }] + path: '.github/workflows/cua-helper-package.yml', head_sha: 'c'.repeat(40), repository: { full_name: repository }, head_repository: { full_name: repository } } + const jobs = [{ jobs: [{ name: 'package', conclusion: 'success', run_attempt: 2 }] }] + const matchesSource = vi.fn((_commit: string) => true) const artifact = { id: 456, name: `cua-helper-123-2-${key}`, expired: false, size_in_bytes: 4096 } const artifacts = [{ artifacts: [{ ...artifact, id: 455, name: `cua-helper-123-1-${key}` }] }, { artifacts: [artifact] }] const api = vi.fn((path: string, paginate?: boolean) => { @@ -18,16 +19,16 @@ function fixture() { expect(paginate).toBe(true) if (path.includes('/workflows/cua-helper-package.yml/runs?')) return [{ workflow_runs: [{ id: 123 }] }] if (path.includes('/workflows/release-publish.yml/runs?')) return [{ workflow_runs: [] }] - if (path === `${base}/attempts/2/jobs?per_page=100`) return jobs + if (path === `${base}/jobs?filter=all&per_page=100`) return jobs if (path === `${base}/artifacts?per_page=100`) return artifacts throw new Error(`Unexpected GitHub request ${path}`) }) - return { run, jobs, artifact, artifacts, api } + return { run, jobs, artifact, artifacts, api, matchesSource } } it.each(['123', ''])('selects a matching artifact with run ID %j and pins the latest attempt', (runId) => { const f = fixture() - expect(selectCuaHelperArtifact(repository, runId, key, f.api)) + expect(selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource)) .toEqual({ runId: '123', attempt: '2', artifactId: '456' }) expect(f.api.mock.calls.filter(([path]) => path === base)).toHaveLength(1) expect(f.api.mock.calls.some(([path]) => path.includes('/workflows/'))).toBe(runId === '') @@ -37,21 +38,21 @@ it('allows successful helper packaging when later npm publication failed', () => const f = fixture() f.run.path = '.github/workflows/release-publish.yml' f.run.conclusion = 'failure' - f.jobs[0]!.jobs = [{ name: 'cua-helper / package', conclusion: 'success' }, { name: 'Publish to npm', conclusion: 'failure' }] - expect(selectCuaHelperArtifact(repository, '123', key, f.api)?.artifactId).toBe('456') + f.jobs[0]!.jobs = [{ name: 'cua-helper / package', conclusion: 'success', run_attempt: 2 }, { name: 'Publish to npm', conclusion: 'failure', run_attempt: 2 }] + expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)?.artifactId).toBe('456') }) it.each(['123/other', ' 123', '0', '-1', '2\nartifact-id=9', 'https://github.com/owner/repo/actions/runs/123'])( 'rejects invalid run ID %j before reading GitHub', (runId) => { const f = fixture() - expect(() => selectCuaHelperArtifact(repository, runId, key, f.api)).toThrow('numeric run ID') + expect(() => selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource)).toThrow('numeric run ID') expect(f.api).not.toHaveBeenCalled() }, ) it.each(['wrong/repo/extra', '../repo', 'owner/..', 'owner/.'])('rejects invalid repository %s before reading GitHub', (repo) => { const f = fixture() - expect(() => selectCuaHelperArtifact(repo, '123', key, f.api)).toThrow('invalid repository') + expect(() => selectCuaHelperArtifact(repo, '123', key, f.api, f.matchesSource)).toThrow('invalid repository') expect(f.api).not.toHaveBeenCalled() }) @@ -66,7 +67,7 @@ it.each(['workflow', 'event', 'repository', 'head_repository', 'id', 'attempt', if (change === 'attempt') f.run.run_attempt = 1 if (change === 'running') f.run.status = 'in_progress' if (change === 'failed') f.run.conclusion = 'failure' - expect(() => selectCuaHelperArtifact(repository, '123', key, f.api)).toThrow() + expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow() expect(f.api.mock.calls.some(([path]) => path.includes('/artifacts'))).toBe(false) }, ) @@ -77,9 +78,9 @@ it.each(['failure', 'cancelled', 'skipped', 'missing', 'duplicate'])( f.run.path = '.github/workflows/release-publish.yml' f.run.conclusion = 'failure' f.jobs[0]!.jobs = status === 'missing' ? [] : status === 'duplicate' - ? [{ name: 'cua-helper / package', conclusion: 'success' }, { name: 'cua-helper / package', conclusion: 'success' }] - : [{ name: 'cua-helper / package', conclusion: status }] - expect(() => selectCuaHelperArtifact(repository, '123', key, f.api)).toThrow('successful cua-helper / package job') + ? [{ name: 'cua-helper / package', conclusion: 'success', run_attempt: 2 }, { name: 'cua-helper / package', conclusion: 'success', run_attempt: 2 }] + : [{ name: 'cua-helper / package', conclusion: status, run_attempt: 2 }] + expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('last executed cua-helper / package job did not succeed') expect(f.api.mock.calls.some(([path]) => path.includes('/attempts/1') || path.includes('/artifacts'))).toBe(false) }, ) @@ -92,8 +93,8 @@ it.each(['missing', 'expired', 'empty', 'mismatch', 'legacy'] as const)( if (change === 'empty') f.artifact.size_in_bytes = 0 if (change === 'mismatch') f.artifact.name = `cua-helper-123-2-${'b'.repeat(64)}` if (change === 'legacy') f.artifact.name = 'cua-helper-123-2' - expect(selectCuaHelperArtifact(repository, '', key, f.api)).toBeUndefined() - expect(() => selectCuaHelperArtifact(repository, '123', key, f.api)).toThrow('CUA reuse: run 123:') + expect(selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined() + expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('CUA reuse: run 123:') }, ) @@ -107,7 +108,7 @@ it('selects a match across workflows and pages after a newer failed producer', ( if (path.endsWith('/runs/124/attempts/1')) return { ...f.run, id: 124, run_attempt: 1, conclusion: 'failure' } return f.api(path, paginate) }) - expect(selectCuaHelperArtifact(repository, '', key, api, new Date('2026-11-03T00:00:00Z'))?.runId).toBe('123') + expect(selectCuaHelperArtifact(repository, '', key, api, f.matchesSource, new Date('2026-11-03T00:00:00Z'))?.runId).toBe('123') const paths = api.mock.calls.map(([path]) => path) expect(paths.find(path => path.includes('/workflows/'))).toContain(encodeURIComponent('>=2026-10-20T00:00:00.000Z')) expect(paths.indexOf(`${base.replace('123', '124')}/attempts/1`)).toBeLessThan(paths.indexOf(base)) @@ -118,12 +119,12 @@ it.each(['duplicate', 'invalid metadata'] as const)('stops automatic discovery o if (change === 'duplicate') f.artifacts.push({ artifacts: [{ ...f.artifact, id: 457 }] }) const api = (path: string, paginate?: boolean): unknown => change === 'invalid metadata' && path.endsWith('/artifacts?per_page=100') ? [{ artifacts: [{ ...f.artifact, expired: 'false' }] }] : f.api(path, paginate) - expect(() => selectCuaHelperArtifact(repository, '', key, api)).toThrow() + expect(() => selectCuaHelperArtifact(repository, '', key, api, f.matchesSource)).toThrow() }) it.each(['', '123'])('propagates unavailable GitHub evidence with selection %j', (runId) => { const api = vi.fn(() => { throw new Error('HTTP 403') }) - expect(() => selectCuaHelperArtifact(repository, runId, key, api)).toThrow('HTTP 403') + expect(() => selectCuaHelperArtifact(repository, runId, key, api, () => true)).toThrow('HTTP 403') expect(api).toHaveBeenCalledTimes(1) }) @@ -131,7 +132,41 @@ it('does not query job or attempt details when artifact names exclude the reques const f = fixture() f.artifact.name = 'cua-helper-123-2-legacy' f.artifacts.shift() - expect(selectCuaHelperArtifact(repository, '', key, f.api)).toBeUndefined() + expect(selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined() expect(f.api.mock.calls.map(([path]) => path).filter(path => !path.includes('/workflows/'))) .toEqual([`${base}/artifacts?per_page=100`]) }) + +it.each(['123', ''])('retains the original helper after a publish-only rerun with selection %j', (runId) => { + const f = fixture() + f.run.path = '.github/workflows/release-publish.yml' + f.jobs[0]!.jobs = [{ name: 'cua-helper / package', conclusion: 'success', run_attempt: 1 }, + { name: 'Publish to npm', conclusion: 'success', run_attempt: 2 }] + f.artifacts.pop() + expect(selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource)) + .toEqual({ runId: '123', attempt: '1', artifactId: '455' }) + expect(f.matchesSource).toHaveBeenCalledExactlyOnceWith(f.run.head_sha) +}) + +it('does not reuse an older successful helper after a newer helper execution failed', () => { + const f = fixture() + f.run.path = '.github/workflows/release-publish.yml' + f.jobs[0]!.jobs = [{ name: 'cua-helper / package', conclusion: 'success', run_attempt: 1 }, + { name: 'cua-helper / package', conclusion: 'failure', run_attempt: 2 }] + expect(selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined() + expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('did not succeed') + expect(f.matchesSource).not.toHaveBeenCalled() +}) + +it('rejects forged artifact keys when independent producer source evidence differs', () => { + const f = fixture() + f.matchesSource.mockReturnValue(false) + expect(selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined() + expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('producer commit') +}) + +it('stops when a new attempt appears during selection', () => { + const f = fixture() + f.jobs[0]!.jobs[0]!.run_attempt = 3 + expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('run advanced') +}) diff --git a/scripts/cua-helper/select-artifact.ts b/scripts/cua-helper/select-artifact.ts index 15865c253c..6a666b83d7 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 { cuaHelperSourceMatches } from './build-inputs.ts' const workflows = ['cua-helper-package.yml', 'release-publish.yml'] as const @@ -33,17 +34,19 @@ export interface CuaHelperSelection { } /** - * Select a matching helper from the latest attempt of an explicit run, or recent completed producer runs. + * Select the last executed helper job, preserving its producer attempt across pack/publish-only reruns. * Automatic misses rebuild; explicit misses and unreadable GitHub evidence fail without selecting another run. * @param repository - Same repository that owns the producer and consumer. * @param runId - Optional decimal run ID; empty selects automatic discovery. * @param buildKey - Source and machine configuration digest required by the consumer. * @param api - Read-only GitHub JSON reader; paginated responses are arrays of pages. + * @param matchesSource - Independent comparison of the producer head SHA with the consuming build inputs. * @param now - Discovery time; automatic search covers the artifact's 14-day retention window. * @returns Matching producer artifact, or undefined when automatic discovery has no match. */ export function selectCuaHelperArtifact(repository: string, runId: string, buildKey: string, - api: (path: string, paginate?: boolean) => unknown, now = new Date()): CuaHelperSelection | undefined { + api: (path: string, paginate?: boolean) => unknown, matchesSource: (commit: string) => boolean, + now = new Date()): CuaHelperSelection | undefined { assert(/^[\w.-]+\/[\w.-]+$/u.test(repository) && !repository.split('/').some(part => part === '.' || part === '..'), 'CUA reuse: invalid repository') assert(runId === '' || /^[1-9]\d*$/u.test(runId), 'CUA reuse: enter the numeric run ID after /actions/runs/ in the run URL') @@ -60,10 +63,14 @@ export function selectCuaHelperArtifact(repository: string, runId: string, build if (run.status !== 'completed') return 'the latest attempt has not completed' if (workflow === 'cua-helper-package.yml' && run.conclusion !== 'success') return 'the latest packaging attempt did not succeed' const jobName = workflow === 'cua-helper-package.yml' ? 'package' : 'cua-helper / package' - const jobs = rows(api(`${base}/runs/${candidate}/attempts/${attempt}/jobs?per_page=100`, true), 'jobs') + const jobs = rows(api(`${base}/runs/${candidate}/jobs?filter=all&per_page=100`, true), 'jobs') .filter(job => job.name === jobName) - if (jobs.length !== 1 || jobs[0]?.conclusion !== 'success') return `the latest attempt has no successful ${jobName} job` - const name = `cua-helper-${candidate}-${attempt}-${buildKey}` + const attempts = jobs.map(job => Number(id(job.run_attempt))) + assert(attempts.every(value => value <= Number(attempt)), 'CUA reuse: run advanced during selection; retry with the same run ID') + const producerAttempt = Math.max(0, ...attempts) + const producers = jobs.filter(job => job.run_attempt === producerAttempt) + if (producers.length !== 1 || producers[0]?.conclusion !== 'success') return `the last executed ${jobName} job did not succeed` + const name = `cua-helper-${candidate}-${producerAttempt}-${buildKey}` const artifacts = (retained ?? rows(api(`${base}/runs/${candidate}/artifacts?per_page=100`, true), 'artifacts')) .filter(artifact => artifact.name === name) if (artifacts.length === 0) return 'no artifact matches the current source bytes and build configuration; use the producer run linked in its summary or rebuild' @@ -71,7 +78,9 @@ export function selectCuaHelperArtifact(repository: string, runId: string, build assert(artifacts.length === 1 && artifact !== undefined, 'CUA reuse: matching artifact is ambiguous') assert(typeof artifact.expired === 'boolean' && typeof artifact.size_in_bytes === 'number', 'CUA reuse: invalid artifact metadata') if (artifact.expired || artifact.size_in_bytes <= 0) return 'the matching artifact is expired or empty' - return { runId: candidate, attempt, artifactId: id(artifact.id) } + assert(typeof run.head_sha === 'string' && /^[a-f0-9]{40}$/u.test(run.head_sha), 'CUA reuse: invalid producer head SHA') + if (!matchesSource(run.head_sha)) return 'the producer commit has different helper build inputs' + return { runId: candidate, attempt: String(producerAttempt), artifactId: id(artifact.id) } } if (runId !== '') { const selected = selectRun(runId) @@ -94,18 +103,39 @@ export function selectCuaHelperArtifact(repository: string, runId: string, build return undefined } +function githubJson(path: string, paginate = false): unknown { + let output: string + try { + output = execFileSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path], + { encoding: 'utf8', timeout: 60_000, maxBuffer: 32 * 1024 * 1024 }) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + throw new Error('CUA reuse: install GitHub CLI (gh) on the Mac runner PATH, then retry this workflow') + } + throw new Error('CUA reuse: GitHub lookup failed; check token permissions, rate limits and connectivity, then retry this workflow', { cause: error }) + } + return JSON.parse(output) +} + if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { 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') } - const buildKey = env.CUA_BUILD_KEY - const selection = selectCuaHelperArtifact(env.GITHUB_REPOSITORY, env.CUA_RUN_ID ?? '', buildKey, (path, paginate) => - JSON.parse(execFileSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path], - { encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }))) - appendFileSync(env.GITHUB_OUTPUT, `artifact-id=${selection?.artifactId ?? ''}\nrun-id=${selection?.runId ?? ''}\nattempt=${selection?.attempt ?? ''}\n`) + const repository = env.GITHUB_REPOSITORY + const root = resolve(import.meta.dirname, '../..') + const sourceMatches = new Map() + const selection = selectCuaHelperArtifact(repository, env.CUA_RUN_ID ?? '', env.CUA_BUILD_KEY, githubJson, (commit) => { + let matches = sourceMatches.get(commit) + if (matches === undefined) { + matches = cuaHelperSourceMatches(root, repository, commit, githubJson) + sourceMatches.set(commit, matches) + } + return matches + }) + appendFileSync(env.GITHUB_OUTPUT, `artifact-id=${selection?.artifactId ?? ''}\nrun-id=${selection?.runId ?? ''}\n`) const summary = selection - ? `复用 [CUA helper 构建](https://github.com/${env.GITHUB_REPOSITORY}/actions/runs/${selection.runId}/attempts/${selection.attempt}),run ID \`${selection.runId}\`,artifact ID \`${selection.artifactId}\`。跳过依赖安装、编译、签名和公证。` + ? `复用 [CUA helper 构建](https://github.com/${repository}/actions/runs/${selection.runId}/attempts/${selection.attempt}),run ID \`${selection.runId}\`,artifact ID \`${selection.artifactId}\`。跳过依赖安装、编译、签名和公证。` : '未找到源码逐字节一致且构建配置匹配的可用 CUA helper 产物,执行依赖安装和重新构建。' appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`) console.log(summary) diff --git a/scripts/cua-helper/verify-tarball.ts b/scripts/cua-helper/verify-tarball.ts index a1a4f0be9e..bb77f7a876 100644 --- a/scripts/cua-helper/verify-tarball.ts +++ b/scripts/cua-helper/verify-tarball.ts @@ -8,7 +8,7 @@ import { verifyCuaHelperPayload } from './payload.ts' /** * Verify both helper applications in the exact provider tarball being published. * @param tarball - pnpm-produced provider package archive. - * @param expected - Product version and source commit of the publishing checkout. + * @param expected - Helper source digest of the publishing checkout. * @returns Resolves only when the archive has complete, digest-verified application generations. */ export async function verifyCuaHelperTarball(tarball: string, expected: { sourceDigest: string }): Promise { diff --git a/scripts/release/publish.spec.ts b/scripts/release/publish.spec.ts index 338df6aa7b..4781766054 100644 --- a/scripts/release/publish.spec.ts +++ b/scripts/release/publish.spec.ts @@ -3,11 +3,13 @@ import { createHash } from 'node:crypto' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { attempt, attemptEchoed, type CommandResult } from './process.ts' import { publishRelease } from './publish.ts' +import { cuaHelperSourceDigest } from '../cua-helper/build-inputs.ts' +import { verifyCuaHelperTarball } from '../cua-helper/verify-tarball.ts' import { packedIdentity, readPublishOrder } from './tarball.ts' vi.mock('./process.ts', async importOriginal => ({ @@ -23,6 +25,8 @@ vi.mock('./tarball.ts', async importOriginal => ({ readPublishOrder: vi.fn(), })) +vi.mock('../cua-helper/verify-tarball.ts', () => ({ verifyCuaHelperTarball: vi.fn() })) + vi.mock('node:timers/promises', async importOriginal => ({ ...await importOriginal(), setTimeout: vi.fn(), @@ -310,3 +314,18 @@ describe('release publication channels', () => { expect(attemptEchoed).not.toHaveBeenCalled() }) }) + +it('verifies helper sources from the repository root when publication is invoked from a subdirectory', async () => { + const fixture = packedRelease(['1.0.0']) + vi.mocked(packedIdentity).mockReturnValue({ name: '@deepseek-ai/dsh-experimental-cua-macos', version: '1.0.0' }) + vi.mocked(verifyCuaHelperTarball).mockResolvedValue(undefined) + registry() + const root = resolve(import.meta.dirname, '../..') + const sourceDigest = cuaHelperSourceDigest(root) + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(join(root, 'scripts')) + try { + await publishRelease('dsh', fixture.directory) + expect(verifyCuaHelperTarball).toHaveBeenCalledExactlyOnceWith(fixture.packages[0]!.path, { sourceDigest }) + expect(attemptEchoed).toHaveBeenCalledOnce() + } finally { cwd.mockRestore() } +}) diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index 9da021a0ec..1ca195b7c6 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -188,7 +188,7 @@ export async function publishRelease(familyId: string, directory: string, distTa }) for (const member of order) { if (member.name !== '@deepseek-ai/dsh-experimental-cua-macos') continue - await verifyCuaHelperTarball(member.tarball, { sourceDigest: cuaHelperSourceDigest(process.cwd()) }) + await verifyCuaHelperTarball(member.tarball, { sourceDigest: cuaHelperSourceDigest(resolve(import.meta.dirname, '../..')) }) } if (distTag !== undefined) { for (const member of order) verifyDistTag(member, distTag) diff --git a/scripts/tests/ci-release-selfhosted.spec.ts b/scripts/tests/ci-release-selfhosted.spec.ts index 48403f79be..a65bbb093e 100644 --- a/scripts/tests/ci-release-selfhosted.spec.ts +++ b/scripts/tests/ci-release-selfhosted.spec.ts @@ -231,6 +231,8 @@ it('skips installation and signing on a helper match and exports the original pr || step.name === 'Install locked dependencies' || step.name === 'Build and verify notarized applications' || step.uses === 'actions/upload-artifact@v7.0.1') expect(expensive).toHaveLength(4) + expect(job.steps.find(step => step.name === 'Install locked dependencies')?.run) + .toBe('pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile') 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)