feat(desktop): 上传固定安装包后刷新 EdgeOne 缓存

This commit is contained in:
std4453 committed 2026-10-26 13:16:07 +08:00
1 parent faa2619933
commit 8e89080ba5
15 files changed
+370 -21

No files matched your search

+6
View File
@@ -56,3 +56,9 @@ APPLE_API_ISSUER=
# DOWNLOAD_PROD_COS_BUCKET=
# DOWNLOAD_PROD_COS_SECRET_ID=
# DOWNLOAD_PROD_COS_SECRET_KEY=
# Production fixed downloads require EO purge and task-query permissions on the COS identity.
# Required for upload:latest:* and purge:latest; configure the download domain's EO zone.
# DOWNLOAD_PROD_EO_ZONE_ID=
# DOWNLOAD_PROD_EO_TIMEOUT_MS=300000
# DOWNLOAD_PROD_EO_POLL_INTERVAL_MS=5000
+6
View File
@@ -41,3 +41,9 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
# DOWNLOAD_PROD_COS_BUCKET=
# DOWNLOAD_PROD_COS_SECRET_ID=
# DOWNLOAD_PROD_COS_SECRET_KEY=
# Production fixed downloads require EO purge and task-query permissions on the COS identity.
# Required for upload:latest:* and purge:latest; configure the download domain's EO zone.
# DOWNLOAD_PROD_EO_ZONE_ID=
# DOWNLOAD_PROD_EO_TIMEOUT_MS=300000
# DOWNLOAD_PROD_EO_POLL_INTERVAL_MS=5000
+4 -2
View File
@@ -43,7 +43,8 @@
"upload:win:x64": "tsx scripts/upload-target.ts win-x64",
"upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest",
"upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest",
"upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest"
"upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest",
"purge:latest": "tsx scripts/upload-target.ts --latest --purge-only"
},
"dependencies": {
"electron-updater": "^6.8.9",
@@ -86,6 +87,7 @@
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"zod": "4.4.3",
"@deepseek-ai/dsh-util-file-drag": "workspace:*"
"@deepseek-ai/dsh-util-file-drag": "workspace:*",
"tencentcloud-sdk-nodejs-teo": "4.1.330"
}
}
+4 -4
View File
@@ -24,11 +24,11 @@
en: 3c3e3a09573c4386
zh: c851ae6a94f2284a
/desktop-publication/fixed-installer-downloads:
en: 38054a5f89590cfa
zh: 9e115f9bdeeab213
en: 2a4c72e884502909
zh: 29b112db69971cc7
/desktop-publication/upload-credentials:
en: 1c5689525753b08d
zh: 93089de765806733
en: 3a14a53c7eca80f7
zh: bb122276f6648f74
/desktop-publication/upload-audit-and-recovery:
en: ec85f43ece09a5e3
zh: 2de5bf801c9c9e39
+6 -2
View File
@@ -69,13 +69,17 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the
## Fixed installer downloads
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit an EdgeOne URL purge and wait for that task to succeed. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading.
An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production-<target>-purge-*` directories under the upload-record root, including URL, task ID, status events and final result. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest <target> --job-id <id>`, or submit a new URL purge with `pnpm run purge:latest <target>` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; EOF, timeout or access denial does not imply EO canceled the task. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh.
<a id="credentials"></a>
## Upload credentials
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For cache-only recovery, add `-PurgeOnly` to `-Upload -Latest -Environment production`; optionally add `-JobId <id>` to resume. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
<a id="audit-and-recovery"></a>
+6 -2
View File
@@ -69,13 +69,17 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo
## 固定安装包下载
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交 EdgeOne URL 缓存清除任务,并等待该任务成功。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限,无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。
缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production-<target>-purge-*` 目录,包含 URL、任务 ID、状态事件和最终结果。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest <target> --job-id <id>` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest <target>` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接结束、超时或权限拒绝不代表 EO 已取消任务。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。
<a id="credentials"></a>
## 上传凭据
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅恢复缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`;可选的 `-JobId <id>` 用于继续等待。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
<a id="audit-and-recovery"></a>
@@ -7,6 +7,13 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production'
/** Directory name of one supported Desktop release target. */
export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64'
/**
* Name the fixed installer object shared by uploading and cache refresh.
* @param target Supported Desktop target.
* @returns Fixed download filename.
*/
export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): string
/** Public updater URL and object prefixes for one release target. */
export interface DesktopAutoUpdateConfig {
readonly environment: DesktopAutoUpdateEnvironment
@@ -35,6 +35,17 @@ const UPDATE_ENVIRONMENTS = {
const UPDATE_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64'])
/**
* Name the fixed installer object shared by uploading and cache refresh.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target Supported Desktop target.
* @returns {string} Fixed download filename.
*/
export function desktopLatestInstallerFilename(target) {
if (!UPDATE_TARGETS.has(target)) throw new Error(`desktop upload: unsupported target ${target}`)
const windows = target === 'win-x64'
return `dsh-latest-${windows ? 'windows' : 'macos'}-${target === 'mac-arm64' ? 'arm64' : 'x64'}.${windows ? 'exe' : 'dmg'}`
}
/**
* Resolve the update deployment, defaulting local release work to test.
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
+162
View File
@@ -0,0 +1,162 @@
/** Refresh production fixed-installer URLs through EdgeOne with resumable task tracking. */
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { teo } from 'tencentcloud-sdk-nodejs-teo'
import type { DesktopPackageTargetName } from './package-target.ts'
import { desktopLatestInstallerFilename, resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
import { recordPackagingEvent } from './packaging-run.mjs'
/** Validated production destination and credentials; never serialize this value. */
export interface DesktopPurgeConfig {
readonly target: DesktopPackageTargetName
readonly zoneId: string
readonly publicUrl: string
readonly secretId: string
readonly secretKey: string
readonly timeoutMs: number
readonly pollIntervalMs: number
}
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]?.trim()
if (!value) throw new DesktopPurgeError(`desktop purge: ${name} must be set in the target dotenv`)
return value
}
function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
if (env[name] === undefined) return fallback
const value = Number(env[name])
if (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647) {
throw new DesktopPurgeError(`desktop purge: ${name} must be a positive timer duration in milliseconds`)
}
return value
}
/**
* Resolve the production URL, EO zone and existing COS credentials before any upload.
* @param env Target dotenv settings, with any credential-launcher replacement already applied.
* @param target Fixed installer selected by the operator.
* @returns Required EO settings; test deployments and invalid settings throw before network writes.
*/
export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName): DesktopPurgeConfig {
if (env.DSH_DESKTOP_AUTO_UPDATE_ENV?.trim() !== 'production') throw new DesktopPurgeError('desktop purge: production is required')
const update = resolveDesktopAutoUpdateConfig(env, target === 'win-x64' ? 'win32' : 'darwin', target === 'mac-arm64' ? 'arm64' : 'x64')
const zoneId = required(env, 'DOWNLOAD_PROD_EO_ZONE_ID')
if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone')
const timeoutMs = milliseconds(env, 'DOWNLOAD_PROD_EO_TIMEOUT_MS', 300_000)
const pollIntervalMs = milliseconds(env, 'DOWNLOAD_PROD_EO_POLL_INTERVAL_MS', 5_000)
if (pollIntervalMs > timeoutMs) throw new DesktopPurgeError('desktop purge: poll interval must not exceed timeout')
return {
target, zoneId, publicUrl: `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`,
secretId: required(env, 'DOWNLOAD_PROD_COS_SECRET_ID'), secretKey: required(env, 'DOWNLOAD_PROD_COS_SECRET_KEY'),
timeoutMs, pollIntervalMs,
}
}
/** A failed or incomplete purge, with only safe diagnostics available to CLI callers. */
export class DesktopPurgeError extends Error {
constructor(message: string) {
super(message)
this.name = 'DesktopPurgeError'
}
}
function safeField(value: unknown, config: DesktopPurgeConfig): string | undefined {
if (typeof value !== 'string' || !/^[\w.-]{1,128}$/u.test(value)
|| value.includes(config.secretId) || value.includes(config.secretKey)) return undefined
return value
}
/**
* Submit one URL purge, or wait for an existing task without submitting again.
* @param config Validated production destination and credentials.
* @param recordsRoot Parent directory for separate purge records; upload records remain intact.
* @param existingJobId EO task to resume; omitted submits once, without automatic write retries.
* @returns Resolves only after EO reports success for the exact target URL and task ID.
* @throws DesktopPurgeError on failure, timeout or unknown submission outcome; uploaded bytes remain unchanged.
*/
export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, existingJobId?: string): Promise<void> {
if (existingJobId !== undefined && !/^[a-zA-Z0-9-]{1,128}$/u.test(existingJobId)) {
throw new DesktopPurgeError('desktop purge: invalid --job-id')
}
let directory: string | undefined
let jobId = existingJobId
let stage = 'prepare'
let success = false
let failure: string | undefined
let rejected = false
let report: DesktopPurgeError | undefined
try {
await mkdir(recordsRoot, { recursive: true })
directory = await mkdtemp(join(recordsRoot, `production-${config.target}-purge-`))
process.stdout.write(`desktop purge: record ${directory}\n`)
await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true })
await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ zoneId: config.zoneId, publicUrl: config.publicUrl,
target: config.target, jobId, timeoutMs: config.timeoutMs, pollIntervalMs: config.pollIntervalMs })}\n`,
{ flag: 'wx', mode: 0o600, flush: true })
const httpProfile = { endpoint: 'teo.tencentcloudapi.com', reqTimeout: Math.min(30, config.timeoutMs / 1000) }
const client = new teo.v20220901.Client({
credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '',
profile: { httpProfile },
})
const deadline = performance.now() + config.timeoutMs
if (jobId === undefined) {
stage = 'CreatePurgeTask'
recordPackagingEvent(directory, { type: 'purge-intent', url: config.publicUrl })
const response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
jobId = safeField(response.JobId, config)
if (jobId) process.stdout.write(`desktop purge: task ${jobId}\n`)
recordPackagingEvent(directory, { type: 'purge-submitted', jobId, requestId: safeField(response.RequestId, config) })
if ((response.FailedList?.length ?? 0) > 0) {
rejected = true
throw new DesktopPurgeError('EO rejected the URL purge')
}
if (!jobId) throw new DesktopPurgeError('EO returned no task ID; submission outcome is unknown')
}
stage = 'DescribePurgeTasks'
while (performance.now() < deadline) {
client.profile.httpProfile = { ...httpProfile, reqTimeout: Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) }
const response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000,
Filters: [{ Name: 'job-id', Values: [jobId] }, { Name: 'target', Values: [config.publicUrl] }] })
if ((response.TotalCount ?? 0) > 1000) throw new DesktopPurgeError('EO task query exceeded its page limit')
const tasks = response.Tasks?.filter(task => task.JobId === jobId && task.Target === config.publicUrl && task.Type === 'purge_url') ?? []
const task = tasks[0]
if (tasks.length > 1) throw new DesktopPurgeError('EO returned multiple entries for the URL and task ID')
const status = task?.Status
recordPackagingEvent(directory, { type: 'purge-status', jobId, status: safeField(status, config), requestId: safeField(response.RequestId, config) })
if (status === 'success') {
success = true
stage = 'complete'
break
}
rejected = status === 'failed' || status === 'timeout' || status === 'canceled'
if (task !== undefined && status !== 'processing') throw new DesktopPurgeError(`EO task did not succeed (${safeField(status, config) ?? 'unknown status'})`)
const remaining = deadline - performance.now()
if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining))
}
if (!success) throw new DesktopPurgeError('timed out waiting for EO; task completion is unknown')
} catch (error) {
const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config)
failure = error instanceof DesktopPurgeError ? error.message : `${stage} failed (${code ?? 'unclassified error'})`
const recovery = rejected
? `Resolve the EO failure, then retry with pnpm run purge:latest ${config.target}.`
: jobId ? `Resume with pnpm run purge:latest ${config.target} --job-id ${jobId}.`
: 'Inspect EO task history before submitting another purge.'
const permission = code?.startsWith('AuthFailure') || code?.startsWith('UnauthorizedOperation')
? ' Check teo:CreatePurgeTask and teo:DescribePurgeTasks permissions.' : ''
report = new DesktopPurgeError(`desktop purge: ${failure}.${permission} ${recovery}`)
} finally {
if (directory !== undefined) {
try {
await writeFile(join(directory, 'result.json'), `${JSON.stringify({ success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`,
{ flag: 'wx', mode: 0o600, flush: true })
} catch (error) {
// An unavailable journal cannot establish completion for a subsequent process.
report = new DesktopPurgeError(`desktop purge: cannot save result; inspect EO task ${jobId ?? 'history'} and ${directory} before recovery`)
}
}
}
if (report !== undefined) throw report
process.stdout.write(`desktop purge: completed ${config.publicUrl}\n`)
}
@@ -15,7 +15,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_PROD_EO_(?:ZONE_ID|TIMEOUT_MS|POLL_INTERVAL_MS)|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
+2 -1
View File
@@ -8,6 +8,7 @@ import { dump, load } from 'js-yaml'
import type { DesktopPackageTargetName } from './package-target.ts'
import {
desktopBuildRecordFilename,
desktopLatestInstallerFilename,
desktopUpdateMetadataFilename,
resolveDesktopUploadConfig,
} from './desktop-auto-update-environment.mjs'
@@ -277,7 +278,7 @@ export async function createDesktopUploadPlan(
contents: dump(published),
}
artifacts.push(channelArtifact)
const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}`
const latestFilename = desktopLatestInstallerFilename(targetName)
const latestKey = `desktop/${latestFilename}`
return {
environment: update.environment,
+30 -7
View File
@@ -1,4 +1,4 @@
/** Upload validated Desktop update artifacts or a fixed installer to Tencent COS. */
/** Upload Desktop artifacts to COS and refresh production fixed downloads through EdgeOne. */
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
@@ -9,6 +9,7 @@ import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs
import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts'
import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts'
import { uploadDesktopRelease } from './desktop-upload-run.ts'
import { DesktopPurgeError, purgeDesktopInstaller, resolveDesktopPurgeConfig } from './desktop-edgeone.ts'
const SUPPORTED_TARGETS = new Set<DesktopPackageTargetName>(['mac-arm64', 'mac-x64', 'win-x64'])
@@ -59,13 +60,15 @@ export function resolveCredentialUploadEnvironment(
}
/**
* Upload the selected target and record a release tag only for production update uploads.
* Upload the selected target, refresh production fixed downloads, or resume only their cache refresh.
* @param args Target and upload options from the command line.
* @returns Resolves after upload and any release-tag attempt complete.
* @returns Resolves after upload, any required EO refresh, and any release-tag attempt complete.
* @throws DesktopPurgeError when refresh is incomplete; a completed upload is not repeated or rolled back.
*/
export async function uploadDesktopTarget(args: string[]): Promise<void> {
const { positionals, values } = parseArgs({ args, allowPositionals: true, options: {
const { positionals, values } = parseArgs({ args: args.filter(arg => arg !== '--'), allowPositionals: true, options: {
latest: { type: 'boolean' },
'purge-only': { type: 'boolean' }, 'job-id': { type: 'string' },
'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' },
} })
const target = positionals[0]
@@ -73,6 +76,9 @@ export async function uploadDesktopTarget(args: string[]): Promise<void> {
throw new Error('desktop upload: expected exactly one target')
}
const name = targetName(target)
if ((values['purge-only'] && !values.latest) || (values['job-id'] !== undefined && !values['purge-only'])) {
throw new Error('desktop upload: --purge-only requires --latest; --job-id requires --purge-only')
}
const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
const launcher = values['credential-launcher'] === true
if (launcher ? values.environment === undefined || values.bucket === undefined
@@ -85,13 +91,26 @@ export async function uploadDesktopTarget(args: string[]): Promise<void> {
const environment = launcher
? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name)
: fileEnvironment
const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records')
if (values['purge-only']) {
await purgeDesktopInstaller(resolveDesktopPurgeConfig(environment, name), recordsRoot, values['job-id'])
return
}
const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true })
const purge = plan.environment === 'production' && values.latest ? resolveDesktopPurgeConfig(environment, name) : undefined
const cos = createDesktopCos({
secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName),
secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName),
})
process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records'))
await uploadDesktopRelease(plan, cos, recordsRoot)
if (purge !== undefined) {
try {
await purgeDesktopInstaller(purge, recordsRoot)
} catch (error) {
throw new DesktopPurgeError(`desktop upload: installer uploaded; cache refresh incomplete. ${error instanceof DesktopPurgeError ? error.message : 'Inspect the purge record before recovery.'}`)
}
}
if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan)
}
@@ -122,8 +141,12 @@ function recordProductionRelease(plan: DesktopUploadPlan): void {
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
uploadDesktopTarget(process.argv.slice(2)).catch(() => {
process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n')
uploadDesktopTarget(process.argv.slice(2)).catch((error) => {
const message = error instanceof DesktopPurgeError ? error.message
: 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.'
// The DPAPI launcher suppresses raw stderr; this error contains only public purge diagnostics.
const output = error instanceof DesktopPurgeError && process.argv.includes('--credential-launcher') ? process.stdout : process.stderr
output.write(`${message}\n`)
process.exitCode = 1
})
}
@@ -18,6 +18,10 @@ COS bucket for an explicit upload. Not needed for the local credential check.
Publish only the installer at its fixed download URL. Requires Upload.
.PARAMETER Upload
Authorize the existing target upload entry. Omit to check credentials locally.
.PARAMETER PurgeOnly
Refresh the production fixed download without uploading. Requires Upload and Latest.
.PARAMETER JobId
Resume an existing EO task without submitting again. Requires PurgeOnly.
#>
[CmdletBinding(DefaultParameterSetName = 'Check')]
param(
@@ -36,7 +40,12 @@ param(
[Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
[switch]$Upload,
[Parameter(ParameterSetName = 'Publish')]
[switch]$Latest
[switch]$Latest,
[Parameter(ParameterSetName = 'Publish')]
[switch]$PurgeOnly,
[Parameter(ParameterSetName = 'Publish')]
[ValidatePattern('^[a-zA-Z0-9-]{1,128}$')]
[string]$JobId
)
$ErrorActionPreference = 'Stop'
@@ -50,6 +59,9 @@ $startInfo = $null
$stage = 'decrypt-file'
try {
if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) {
throw 'PurgeOnly requires Latest and production; JobId requires PurgeOnly.'
}
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
throw 'Windows DPAPI is required.'
}
@@ -92,7 +104,13 @@ try {
$startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket
$startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket"
if ($Latest) { $startInfo.Arguments += " --latest" }
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
if ($PurgeOnly) { $startInfo.Arguments += " --purge-only" }
if ($JobId) { $startInfo.Arguments += " --job-id $JobId" }
if ($PurgeOnly) {
Write-Output "desktop credentials: refreshing $Target in $Environment; no installer upload."
} else {
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
}
} else {
$probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)"
$startInfo.Arguments = "-e `"$probe`""
+1
View File
@@ -48,6 +48,7 @@
"upload:latest:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:arm64",
"upload:latest:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:x64",
"upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64",
"purge:latest": "pnpm --filter @deepseek-ai/dsh-desktop run purge:latest",
"clean": "tsx scripts/clean.ts",
"change-scope": "tsx scripts/change-scope.ts",
"typecheck": "tsx scripts/build.ts --scope typecheck",
+104
View File
@@ -810,6 +810,9 @@ importers:
sharp:
specifier: 0.35.5
version: 0.35.5(@types/[email protected])
tencentcloud-sdk-nodejs-teo:
specifier: 4.1.330
version: 4.1.330([email protected])
typescript:
specifier: ^6.0.3
version: 6.0.3
@@ -21128,6 +21131,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
[email protected]:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -22396,6 +22403,17 @@ packages:
resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
engines: {node: '>= 0.12'}
[email protected]:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
resolution: {integrity: sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
@@ -22499,6 +22517,10 @@ packages:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
@@ -22674,6 +22696,10 @@ packages:
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
engines: {node: '>=10.19.0'}
[email protected]:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -22738,6 +22764,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
[email protected]:
resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
@@ -22801,6 +22831,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
[email protected]:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
@@ -24638,6 +24672,14 @@ packages:
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
engines: {node: '>=6.0.0'}
[email protected]:
resolution: {integrity: sha512-FlRJzgFYqSz1blKyN39Jhy/WdK3VuyFkn2+naKJfpwWcSpLF0F9AqSzPAtgorb5/Ezs8sLmFSCuaqFcCw+uIWw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-t23eGzL646ldJMWH/zfdyjsnjDvphYj5ccoKu5nr8ToI1O9u6VVuTHF1fSTlqSZF227qILh8pl/f894BTHIp3w==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
@@ -24776,6 +24818,9 @@ packages:
unrun:
optional: true
[email protected]:
resolution: {integrity: sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==}
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -24946,6 +24991,11 @@ packages:
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
[email protected]:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
[email protected]:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -28575,6 +28625,12 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
dependencies:
debug: 4.4.3([email protected])
transitivePeerDependencies:
- supports-color
[email protected]: {}
[email protected]: {}
@@ -30117,6 +30173,14 @@ snapshots:
mime-types: 2.1.35
safe-buffer: 5.2.1
[email protected]:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
[email protected]:
dependencies:
asynckit: 0.4.0
@@ -30240,6 +30304,8 @@ snapshots:
dependencies:
pump: 3.0.4
[email protected]: {}
[email protected]:
dependencies:
'@sec-ant/readable-stream': 0.4.1
@@ -30493,6 +30559,13 @@ snapshots:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
[email protected]([email protected]):
dependencies:
agent-base: 6.0.2([email protected])
debug: 4.4.3([email protected])
transitivePeerDependencies:
- supports-color
[email protected]([email protected]):
dependencies:
agent-base: 7.1.4
@@ -30550,6 +30623,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -30588,6 +30663,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -32659,6 +32736,29 @@ snapshots:
mkdirp: 0.5.6
rimraf: 2.6.3
[email protected]([email protected]):
dependencies:
form-data: 3.0.5
get-stream: 6.0.1
https-proxy-agent: 5.0.1([email protected])
ini: 2.0.0
is-stream: 2.0.1
json-bigint: 1.0.0
node-fetch: 2.7.0
tslib: 1.13.0
uuid: 9.0.1
transitivePeerDependencies:
- encoding
- supports-color
[email protected]([email protected]):
dependencies:
tencentcloud-sdk-nodejs-common: 4.1.220([email protected])
tslib: 1.13.0
transitivePeerDependencies:
- encoding
- supports-color
[email protected]:
dependencies:
b4a: 1.8.1
@@ -32768,6 +32868,8 @@ snapshots:
- oxc-resolver
- vue-tsc
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -32935,6 +33037,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: