From 61059baf613922ebf7cce02b0d5e65d76ff905c3 Mon Sep 17 00:00:00 2001 From: winewill Date: Thu, 15 Oct 2026 13:39:28 +0800 Subject: [PATCH] feat(desktop): allow repository branches for test packaging --- .github/workflows/desktop-publish.yml | 4 +- .github/workflows/macos-package.yml | 29 +++++++---- .github/workflows/windows-package.yml | 29 +++++++---- apps/desktop/publishing/ci/README.i18n.yaml | 4 +- apps/desktop/publishing/ci/README.md | 2 +- apps/desktop/publishing/ci/README.zh.md | 2 +- apps/desktop/scripts/desktop-publish-route.ts | 5 +- apps/desktop/scripts/desktop-release-store.ts | 5 +- .../resolve-retained-desktop-publish-ci.ts | 8 +-- .../tests/desktop-package-selection.spec.ts | 14 +++++- .../tests/desktop-publish-route.spec.ts | 9 ++++ .../tests/desktop-release-store.spec.ts | 13 +++++ scripts/desktop-package-source.spec.ts | 49 +++++++++++++++++++ scripts/macos-package-workflow.spec.ts | 2 +- 14 files changed, 140 insertions(+), 35 deletions(-) create mode 100644 scripts/desktop-package-source.spec.ts diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 8f9c82af11..89e9e1da4e 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -6,11 +6,11 @@ on: workflow_dispatch: inputs: package_run_id: - description: 'Successful Package Desktop run ID' + description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包' type: string required: true package_run_attempt: - description: 'Package attempt; blank selects latest, which must have succeeded' + description: '打包尝试次数:留空选择最新一次,且必须成功;失败时不会回退到旧包' type: string required: false default: '' diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 1d6b8c6309..91623da431 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -4,40 +4,40 @@ on: workflow_dispatch: inputs: preset: - description: 'Machine preset (configured on the packaging runner)' + description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' type: choice options: [test, production] default: test required: true skip_notarization: - description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates' + description: '跳过公证和 stapling:仅限内部调试,不可用于发布或自动更新' type: boolean default: false required: true test_release_id: - description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。' + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' type: string default: '' required: false source_ref: - description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' type: string default: master required: true update_channel: - description: 'Installer update channel; automatic updates stay on this channel' + description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' type: choice options: [nightly, latest] default: nightly required: true version_mode: - description: 'default: production follows product, test auto; manual uses build_version' + description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' type: choice options: [default, product, auto, manual] default: default required: true build_version: - description: 'Manual version only: product version or its supported prerelease suffix' + description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀' type: string default: '' required: false @@ -74,9 +74,20 @@ jobs: - name: Validate selected source and adapter support run: >- node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; - if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET; + if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset'); + const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref); + if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*'); + if(tag) { const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; - if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + if(ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim(); + const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(); + if(tagged!==head) throw Error('Checkout does not match source tag'); + } else { + const branch=cp.execFileSync('git',['symbolic-ref','--short','HEAD'],{encoding:'utf8'}).trim(); + if(branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); + } if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 589917fa4e..0d7bee7342 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -4,41 +4,41 @@ on: workflow_dispatch: inputs: preset: - description: 'Machine preset (configured on the packaging runner)' + description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' type: choice options: [test, production] default: test required: true signing: - description: 'unsigned: test installer; signed: use the configured EV hardware token' + description: '签名方式:unsigned 仅供内部测试;signed 使用机器配置的 EV 硬件令牌' type: choice options: [unsigned, signed] default: unsigned required: true test_release_id: - description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。' + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' type: string default: '' required: false source_ref: - description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' type: string default: master required: true update_channel: - description: 'Installer update channel; automatic updates stay on this channel' + description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' type: choice options: [nightly, latest] default: nightly required: true version_mode: - description: 'default: production follows product, test auto; manual uses build_version' + description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' type: choice options: [default, product, auto, manual] default: default required: true build_version: - description: 'Manual version only: product version or its supported prerelease suffix' + description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀' type: string default: '' required: false @@ -86,9 +86,20 @@ jobs: - name: Validate selected source and adapter support run: >- node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; - if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET; + if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset'); + const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref); + if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*'); + if(tag) { const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; - if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + if(ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim(); + const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(); + if(tagged!==head) throw Error('Checkout does not match source tag'); + } else { + const branch=cp.execFileSync('git',['symbolic-ref','--short','HEAD'],{encoding:'utf8'}).trim(); + if(branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); + } if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 72053a7e6f..a9120c7977 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -18,8 +18,8 @@ en: 990ff7e56dd6005a zh: 18f9dc23c520dc8c /desktop-ci-publication/package-and-publish: - en: 1aaab994b182e893 - zh: 54bc0689b1f3a7c5 + en: 46aaf2a0e7a818cc + zh: aa2de110b2ee084d /desktop-ci-publication/recovery-and-records: en: e92bfad06162443e zh: 6c3e3d8ece673217 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 09920d83c1..4b7651369e 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -35,7 +35,7 @@ Environment approval gates this workflow, not arbitrary code running under the s ## Package and publish -Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts `master` or `dsh-v*` matching the selected product version; the selected source must contain these CI adapters, so older tags fail before dependency installation. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Tags must match the selected product version. Dispatch the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 2362c427b7..9b5248e015 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -35,7 +35,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 打包与发布 -选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。`source_ref` 接受 `master` 或与所选产品版本一致的 `dsh-v*`;源码必须包含这些 CI 适配脚本,因此旧 tag 会在安装依赖前失败。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。tag 必须与所选产品版本一致。从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 8914090284..b452eab3e1 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -9,11 +9,12 @@ const runSchema = z.object({ }) const requestSchema = z.object({ source: z.object({ repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), - workflowCommit: z.string(), sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + workflowCommit: z.string(), sourceRef: z.string().min(1), channel: z.enum(['nightly', 'latest']), runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), qualification: z.enum(['signed', 'notarized']), -}) }) +}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), + 'Production source must be master or dsh-v*') }) const jobsSchema = z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable() })) const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index 1c9ff23951..c080272e1d 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -13,7 +13,7 @@ const sourceSchema = z.strictObject({ attempt: positiveId, commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u), - sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + sourceRef: z.string().min(1), channel: z.enum(['nightly', 'latest']), runnerName: z.string().min(1), // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. @@ -25,7 +25,8 @@ const sourceSchema = z.strictObject({ // Older retained manifests carry this flag; manual publication does not depend on it. publishRequested: z.boolean().optional(), qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), -}) +}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), + 'Production source must be master or dsh-v*') const manifestSchema = z.strictObject({ schemaVersion: z.literal(1), source: sourceSchema, diff --git a/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts index 6086b28cc7..d892fe6906 100644 --- a/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts @@ -22,11 +22,11 @@ export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv, const repository = env.GITHUB_REPOSITORY const source = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${route.commit}`)) if (source.sha !== route.commit) throw new Error('desktop publish: source commit not found') - if (route.sourceRef === 'master') { - const comparison = z.object({ status: z.string() }).parse(await api(`repos/${repository}/compare/${route.commit}...master`)) - if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on master') + if (!/^dsh-v[0-9][0-9A-Za-z.-]*$/u.test(route.sourceRef)) { + const comparison = z.object({ status: z.string() }).parse(await api(`repos/${repository}/compare/${route.commit}...${encodeURIComponent(`refs/heads/${route.sourceRef}`)}`)) + if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on the selected repository branch') } else { - const tagged = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${route.sourceRef}`)) + const tagged = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${encodeURIComponent(`refs/tags/${route.sourceRef}`)}`)) if (tagged.sha !== route.commit) throw new Error('desktop publish: source tag changed') } const environment = z.object({ protection_rules: z.array(z.object({ type: z.string(), reviewers: z.array(z.unknown()).optional() })) }) diff --git a/apps/desktop/tests/desktop-package-selection.spec.ts b/apps/desktop/tests/desktop-package-selection.spec.ts index f341a918f4..fa6a042775 100644 --- a/apps/desktop/tests/desktop-package-selection.spec.ts +++ b/apps/desktop/tests/desktop-package-selection.spec.ts @@ -95,14 +95,24 @@ it('resolves an old locally retained package with publishRequested=false without await expect(resolveRetainedDesktopPublication(env, path => path.includes('/environments/') ? { protection_rules: [] } : api(path))) .rejects.toThrow(/reviewers/u) await expect(resolveRetainedDesktopPublication(env, path => path.includes('/compare/') ? { status: 'diverged' } : api(path))) - .rejects.toThrow(/not on master/u) + .rejects.toThrow(/not on the selected repository branch/u) const manifestPath = join(root, 'releases/42/2/manifest.json') const original = await readFile(manifestPath, 'utf8') const tagged = await verifyRetainedDesktopRelease(join(root, 'releases'), '42', '2') + tagged.source.sourceRef = 'feature/desktop-update' + await writeFile(manifestPath, JSON.stringify(tagged)) + expect(await resolveRetainedDesktopPublication(env, api)).toMatchObject({ sourceRef: 'feature/desktop-update', commit: source.commit }) + expect(apiCalls).toContain(`repos/owner/repo/compare/${source.commit}...refs%2Fheads%2Ffeature%2Fdesktop-update`) + await expect(resolveRetainedDesktopPublication(env, path => path.includes('/compare/') ? { status: 'diverged' } : api(path))) + .rejects.toThrow(/selected repository branch/u) + await expect(resolveRetainedDesktopPublication(env, (path) => { + if (path.includes('/compare/')) throw new Error('branch deleted or missing') + return api(path) + })).rejects.toThrow(/branch deleted/u) tagged.source.sourceRef = 'dsh-v1.0.0' await writeFile(manifestPath, JSON.stringify(tagged)) expect(await resolveRetainedDesktopPublication(env, api)).toMatchObject({ sourceRef: 'dsh-v1.0.0' }) - await expect(resolveRetainedDesktopPublication(env, path => path.endsWith('/commits/dsh-v1.0.0') ? { sha: 'c'.repeat(40) } : api(path))) + await expect(resolveRetainedDesktopPublication(env, path => path.endsWith('/commits/refs%2Ftags%2Fdsh-v1.0.0') ? { sha: 'c'.repeat(40) } : api(path))) .rejects.toThrow(/source tag changed/u) await writeFile(manifestPath, original) await writeFile(join(root, 'releases/42/2/installer.dmg'), 'changed') diff --git a/apps/desktop/tests/desktop-publish-route.spec.ts b/apps/desktop/tests/desktop-publish-route.spec.ts index a4e7dea274..b086fb0cf0 100644 --- a/apps/desktop/tests/desktop-publish-route.spec.ts +++ b/apps/desktop/tests/desktop-publish-route.spec.ts @@ -56,3 +56,12 @@ it('ignores unrelated successful jobs on the packaging machine', () => { f.jobs.push({ ...f.jobs[0]!, name: 'diagnostics' }) expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toMatchObject({ runnerName: 'mini' }) }) + +it('allows repository branch packages for test but rejects them for production', () => { + const f = fixture() + f.source.sourceRef = 'feature/desktop-update' + expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)) + .toMatchObject({ sourceRef: f.source.sourceRef, preset: 'test' }) + f.source.preset = 'production' + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow(/Production source/u) +}) diff --git a/apps/desktop/tests/desktop-release-store.spec.ts b/apps/desktop/tests/desktop-release-store.spec.ts index 7c717aa90e..899b38b8cf 100644 --- a/apps/desktop/tests/desktop-release-store.spec.ts +++ b/apps/desktop/tests/desktop-release-store.spec.ts @@ -69,3 +69,16 @@ it('rejects traversal and manifest identity or file inventory tampering', async await writeFile(join(path, 'manifest.json'), JSON.stringify({ ...manifest, files: [{ ...manifest.files[0], name: '../installer.exe' }] })) await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow() }) + +it('retains test branch identity while rejecting production branch manifests', async () => { + const { root, file, source } = await fixture() + source.sourceRef = 'feature/desktop-update' + await expect(retainDesktopRelease(root, { ...source, preset: 'production' }, [file])).rejects.toThrow(/Production source/u) + const directory = await retainDesktopRelease(root, source, [file]) + expect((await verifyRetainedDesktopRelease(root, source.runId, source.attempt)).source.sourceRef).toBe(source.sourceRef) + const path = join(directory, 'manifest.json') + const manifest = await verifyRetainedDesktopRelease(root, source.runId, source.attempt) + manifest.source.preset = 'production' + await writeFile(path, JSON.stringify(manifest)) + await expect(verifyRetainedDesktopRelease(root, source.runId, source.attempt)).rejects.toThrow(/Production source/u) +}) diff --git a/scripts/desktop-package-source.spec.ts b/scripts/desktop-package-source.spec.ts new file mode 100644 index 0000000000..1422d7e939 --- /dev/null +++ b/scripts/desktop-package-source.spec.ts @@ -0,0 +1,49 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import yaml from 'js-yaml' +import { expect, it } from 'vitest' + +it.each(['macos', 'windows'])('%s packaging accepts test branches but preserves production and tag restrictions', (platform) => { + const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, `../.github/workflows/${platform}-package.yml`), 'utf8')) as { + jobs: { package: { steps: Array<{ name?: string; run?: string }> } } + } + const command = workflow.jobs.package.steps.find(step => step.name === 'Validate selected source and adapter support')!.run! + const script = command.slice('node -e "'.length, -1) + const root = mkdtempSync(join(tmpdir(), 'desktop-source-')) + try { + const git = (...args: string[]) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' }).trim() + git('init', '--initial-branch=master') + mkdirSync(join(root, 'apps/desktop/scripts'), { recursive: true }) + writeFileSync(join(root, 'package.json'), '{"version":"1.0.0"}') + writeFileSync(join(root, 'apps/desktop/scripts/desktop-ci-version.mjs'), 'PACKAGE_VERSION_MODE') + git('add', '.') + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture') + const sha = git('rev-parse', 'HEAD') + git('tag', 'dsh-v1.0.0') + git('tag', 'dsh-v2.0.0') + const run = (preset: string, ref: string) => { + const result = spawnSync(process.execPath, ['-e', script], { + cwd: root, encoding: 'utf8', env: { ...process.env, DSH_PACKAGING_PRESET: preset, PACKAGE_SOURCE_REF: ref }, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + return result.status + } + for (const preset of ['test', 'production']) expect(run(preset, 'master')).toBe(0) + git('checkout', '-b', 'feature/desktop-update') + expect(run('test', 'feature/desktop-update')).toBe(0) + expect(run('production', 'feature/desktop-update')).not.toBe(0) + expect(run('test', 'refs/pull/42/head')).not.toBe(0) + expect(run('test', 'refs/heads/feature/desktop-update')).not.toBe(0) + expect(run('unknown', 'feature/desktop-update')).not.toBe(0) + git('checkout', '--detach', sha) + expect(run('test', sha)).not.toBe(0) + expect(run('test', 'master')).not.toBe(0) + for (const preset of ['test', 'production']) expect(run(preset, 'dsh-v1.0.0')).toBe(0) + expect(run('test', 'dsh-v2.0.0')).not.toBe(0) + writeFileSync(join(root, 'apps/desktop/scripts/desktop-ci-version.mjs'), 'old adapter') + expect(run('test', 'dsh-v1.0.0')).not.toBe(0) + } finally { rmSync(root, { recursive: true, force: true, maxRetries: 3 }) } +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index d05a5f1ecf..ad6a28ed76 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -34,7 +34,7 @@ it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silic it('defaults to notarization and labels the explicit debugging option', () => { expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { skip_notarization: { type: 'boolean', default: false, - description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates', + description: '跳过公证和 stapling:仅限内部调试,不可用于发布或自动更新', } } }) const steps = workflow.jobs.package!.steps expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2)