refactor(desktop): simplify EdgeOne purge to submission only
This commit is contained in:
1 parent
ad5dfe7658
commit
3969c29fa5
14 files changed
+125
-418
No files matched your search
@@ -57,8 +57,6 @@ APPLE_API_ISSUER=
|
||||
# DOWNLOAD_PROD_COS_SECRET_ID=
|
||||
# DOWNLOAD_PROD_COS_SECRET_KEY=
|
||||
|
||||
# Production fixed downloads require EO purge and task-query permissions on the COS identity.
|
||||
# Production fixed downloads require teo:CreatePurgeTask permission 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
|
||||
@@ -42,8 +42,6 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
|
||||
# DOWNLOAD_PROD_COS_SECRET_ID=
|
||||
# DOWNLOAD_PROD_COS_SECRET_KEY=
|
||||
|
||||
# Production fixed downloads require EO purge and task-query permissions on the COS identity.
|
||||
# Production fixed downloads require teo:CreatePurgeTask permission 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
|
||||
@@ -24,11 +24,11 @@
|
||||
en: 3c3e3a09573c4386
|
||||
zh: c851ae6a94f2284a
|
||||
/desktop-publication/fixed-installer-downloads:
|
||||
en: fc2d126835d00f12
|
||||
zh: fceda915f9ce5558
|
||||
en: ce8296f8f1ee881e
|
||||
zh: 749ab63bd6ed4248
|
||||
/desktop-publication/upload-credentials:
|
||||
en: 3a14a53c7eca80f7
|
||||
zh: bb122276f6648f74
|
||||
en: 47442fb912fa4d5d
|
||||
zh: a21a0d67cd01724b
|
||||
/desktop-publication/upload-audit-and-recovery:
|
||||
en: ec85f43ece09a5e3
|
||||
zh: 2de5bf801c9c9e39
|
||||
|
||||
@@ -69,17 +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. 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.
|
||||
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 one EdgeOne URL purge and report its task ID; they do not query or wait for cache completion. 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. Existing key permissions are not verified by this script change; before enabling production fixed uploads, the release operator must confirm both EO actions for the selected identity. 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.
|
||||
Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv and `teo:CreatePurgeTask` permission for that zone on the existing `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` identity. The release operator must confirm that permission before use; no additional CI Secret is required. Required settings are resolved before uploading. The submission has a 30-second request timeout and is never retried automatically.
|
||||
|
||||
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 versioned plans/results, script fingerprints, URL, task ID and status events. 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; connection loss, timeout or access denial does not imply EO canceled the task. Transient query failures retry within the deadline; permission and parameter errors stop immediately. A task never found by the deadline is reported separately from an unfinished task. DPAPI recovery must reuse the original credential launcher with the same credential file, deployment, bucket and target, adding `-PurgeOnly` and the accepted `-JobId`. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh.
|
||||
The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest <target>` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The CI publisher does not publish 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. 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.
|
||||
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 a cache-only submission, add `-PurgeOnly` to `-Upload -Latest -Environment production`. 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>
|
||||
|
||||
|
||||
@@ -69,17 +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。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交 EdgeOne URL 缓存清除任务,并等待该任务成功。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 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 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
|
||||
|
||||
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。本次脚本修改未验证现有密钥权限;启用生产固定上传前,发布操作人员必须确认所选身份具备这两个 EO 操作的权限。无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。
|
||||
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。
|
||||
|
||||
缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production-<target>-purge-*` 目录,包含带版本号的计划和结果、脚本指纹、URL、任务 ID 和状态事件。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest <target> --job-id <id>` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest <target>` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接中断、超时或权限拒绝不代表 EO 已取消任务。临时查询错误在截止时间内重试;权限和参数错误立即停止。截止时间前始终未找到任务时,单独报告该情况,与任务尚未完成区分。DPAPI 恢复必须复用原凭据启动器以及相同的凭据文件、部署环境、bucket 和目标,添加 `-PurgeOnly` 和已接受的 `-JobId`。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。
|
||||
命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest <target>` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。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`,即可替换固定安装包,而不发布更新清单或发布标签。仅恢复缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`;可选的 `-JobId <id>` 用于继续等待。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 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`。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
|
||||
|
||||
<a id="audit-and-recovery"></a>
|
||||
|
||||
|
||||
@@ -1,76 +1,43 @@
|
||||
/** Refresh production fixed-installer URLs through EdgeOne with resumable task tracking. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
/** Submit one production fixed-installer cache purge to EdgeOne. */
|
||||
import type { teo } from 'tencentcloud-sdk-nodejs-teo'
|
||||
import type { DesktopPackageTargetName } from './package-target.ts'
|
||||
import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { desktopTargetPlatform } from './desktop-build-paths.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
|
||||
}
|
||||
|
||||
/** SDK operations used by one purge; callers may supply an isolated client. */
|
||||
export type DesktopPurgeClient = Pick<InstanceType<typeof teo.v20220901.Client>, 'profile' | 'CreatePurgeTask' | 'DescribePurgeTasks'>
|
||||
|
||||
/** Optional existing task, credential-launcher context, and caller-owned SDK client. */
|
||||
export interface DesktopPurgeOptions {
|
||||
readonly jobId?: string
|
||||
readonly credentialLauncher?: boolean
|
||||
readonly client?: DesktopPurgeClient
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
/** The single SDK operation needed to submit a purge. */
|
||||
export type DesktopPurgeClient = Pick<InstanceType<typeof teo.v20220901.Client>, 'CreatePurgeTask'>
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Resolve production purge settings before uploading.
|
||||
* @param env Target dotenv settings, including any credential-launcher replacement.
|
||||
* @param target Fixed installer selected by the operator.
|
||||
* @param publicUrl Uploaded object URL; omitted derives the fixed URL for cache-only recovery.
|
||||
* @returns Required EO settings; test deployments and invalid settings throw before network writes.
|
||||
* @param publicUrl Uploaded object URL; omitted derives the URL for a cache-only submission.
|
||||
* @returns Validated destination and credentials.
|
||||
*/
|
||||
export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName,
|
||||
publicUrl?: string,
|
||||
export function resolveDesktopPurgeConfig(
|
||||
env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string,
|
||||
): DesktopPurgeConfig {
|
||||
if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required')
|
||||
const { platform, arch } = desktopTargetPlatform(target)
|
||||
const update = resolveDesktopUploadConfig(env, platform, arch)
|
||||
const zoneId = requiredEnvironmentValue(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: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`,
|
||||
zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`,
|
||||
secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName),
|
||||
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'
|
||||
}
|
||||
}
|
||||
/** A purge submission failure containing only safe CLI diagnostics. */
|
||||
export class DesktopPurgeError extends Error {}
|
||||
|
||||
function safeField(value: unknown, config: DesktopPurgeConfig): string | undefined {
|
||||
if (typeof value !== 'string' || !/^[\w.-]{1,128}$/u.test(value)
|
||||
@@ -78,137 +45,29 @@ function safeField(value: unknown, config: DesktopPurgeConfig): string | undefin
|
||||
return value
|
||||
}
|
||||
|
||||
function errorCode(error: unknown, config: DesktopPurgeConfig): string | undefined {
|
||||
return safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config)
|
||||
}
|
||||
|
||||
function transientQueryError(error: unknown, config: DesktopPurgeConfig): boolean {
|
||||
const code = errorCode(error, config)
|
||||
// The pinned SDK wraps transport failures without preserving their original code.
|
||||
if (code === undefined && error instanceof Error && 'getRequestId' in error && 'getTraceId' in error) {
|
||||
return !('httpCode' in error) || (typeof error.httpCode === 'number' && (error.httpCode === 429 || error.httpCode >= 500))
|
||||
}
|
||||
return code !== undefined && (['RequestLimitExceeded', 'InternalError'].includes(code.split('.')[0]!)
|
||||
|| ['ClientNetworkError', 'ClientNetworkSocketTimeoutError', 'ClientNetworkConnectTimeoutError',
|
||||
'RequestTimeout', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code))
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit one URL purge, or wait for an existing task without submitting again.
|
||||
* Submit once and report the accepted task ID, without querying cache completion.
|
||||
* @param config Validated production destination and credentials.
|
||||
* @param recordsRoot Parent directory for separate purge records; upload records remain intact.
|
||||
* @param options Existing task, launcher context, and optional SDK client; submission is never retried.
|
||||
* @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.
|
||||
* @param client Optional caller-owned SDK client; omitted lazily creates one with a 30-second request timeout.
|
||||
* @returns Resolves when EO accepts the submission; this does not confirm cache completion.
|
||||
* @throws DesktopPurgeError on rejection or an unconfirmed submission; never retries or changes uploaded bytes.
|
||||
*/
|
||||
export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string,
|
||||
options: DesktopPurgeOptions = {},
|
||||
): Promise<void> {
|
||||
const existingJobId = options.jobId
|
||||
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 observedTask = false
|
||||
let lastQueryFailed = false
|
||||
let failure: string | undefined
|
||||
let rejected = false
|
||||
let report: DesktopPurgeError | undefined
|
||||
export async function purgeDesktopInstaller(config: DesktopPurgeConfig, client?: DesktopPurgeClient): Promise<void> {
|
||||
let response: Awaited<ReturnType<DesktopPurgeClient['CreatePurgeTask']>>
|
||||
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 })
|
||||
const sourceSha256 = { 'desktop-edgeone.ts': createHash('sha256').update(await readFile(import.meta.filename)).digest('hex') }
|
||||
await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1, sourceSha256, 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 = options.client ?? new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({
|
||||
client ??= new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({
|
||||
credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '',
|
||||
profile: { httpProfile },
|
||||
profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } },
|
||||
})
|
||||
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!.reqTimeout = Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000))
|
||||
let response: Awaited<ReturnType<DesktopPurgeClient['DescribePurgeTasks']>>
|
||||
try {
|
||||
response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000,
|
||||
Filters: [{ Name: 'job-id', Values: [jobId] }] })
|
||||
} catch (error) {
|
||||
const code = errorCode(error, config)
|
||||
if (!transientQueryError(error, config)) throw error
|
||||
lastQueryFailed = true
|
||||
recordPackagingEvent(directory, { type: 'purge-query-retry', jobId, code: code ?? 'SDKTransportError' })
|
||||
const remaining = deadline - performance.now()
|
||||
if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining))
|
||||
continue
|
||||
}
|
||||
lastQueryFailed = false
|
||||
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') ?? []
|
||||
if ((response.Tasks?.length ?? 0) > 0 && tasks.length === 0) {
|
||||
throw new DesktopPurgeError('EO job does not contain the expected URL purge; check the zone, job ID and target')
|
||||
}
|
||||
const task = tasks[0]
|
||||
observedTask ||= task !== undefined
|
||||
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(observedTask || lastQueryFailed
|
||||
? 'timed out waiting for EO; task completion is unknown'
|
||||
: 'EO task was not found before the deadline; check the zone, job ID and EO task history')
|
||||
response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
|
||||
} catch (error) {
|
||||
const code = errorCode(error, config)
|
||||
failure = error instanceof DesktopPurgeError ? error.message : `${stage} failed (${code ?? 'unclassified error'})`
|
||||
const command = options.credentialLauncher
|
||||
? `rerun upload-with-credentials.ps1 with the original -CredentialFile, -Environment production, -Target ${config.target} and -Bucket; include -Upload -Latest -PurgeOnly${!rejected && jobId ? ` -JobId ${jobId}` : ''}`
|
||||
: `pnpm run purge:latest ${config.target}${!rejected && jobId ? ` --job-id ${jobId}` : ''}`
|
||||
const recovery = rejected
|
||||
? `Resolve the EO failure, then ${command}.`
|
||||
: jobId ? `Resume with ${command}.`
|
||||
: '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({ schemaVersion: 1, 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`)
|
||||
}
|
||||
}
|
||||
const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config)
|
||||
throw new DesktopPurgeError(`desktop purge: submission not confirmed (${code ?? 'request failed'}); check EO task history and teo:CreatePurgeTask permission before submitting again.`)
|
||||
}
|
||||
if (report !== undefined) throw report
|
||||
process.stdout.write(`desktop purge: completed ${config.publicUrl}\n`)
|
||||
if ((response.FailedList?.length ?? 0) > 0) {
|
||||
throw new DesktopPurgeError('desktop purge: EO rejected the URL; inspect EO before submitting again.')
|
||||
}
|
||||
const jobId = safeField(response.JobId, config)
|
||||
if (!jobId) throw new DesktopPurgeError('desktop purge: EO returned no task ID; check EO task history before submitting again.')
|
||||
process.stdout.write(`desktop purge: submitted ${config.publicUrl}; task ${jobId}. Cache completion is not checked.\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_PROD_EO_(?:ZONE_ID|TIMEOUT_MS|POLL_INTERVAL_MS)|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|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
|
||||
|
||||
@@ -13,7 +13,7 @@ const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket',
|
||||
'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT'])
|
||||
|
||||
/** Object keys fingerprinted into every upload record to bind evidence to this uploader. */
|
||||
const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts', 'desktop-edgeone.ts']
|
||||
const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts']
|
||||
|
||||
async function fingerprint(artifact: DesktopUploadArtifact) {
|
||||
const sha512 = createHash('sha512')
|
||||
|
||||
@@ -64,15 +64,15 @@ function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTarge
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the selected target, refresh production fixed downloads, or resume only their cache refresh.
|
||||
* Upload the selected target, refresh production fixed downloads, or submit only their cache purge.
|
||||
* @param args Target and upload options from the command line.
|
||||
* @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.
|
||||
* @returns Resolves after upload, any required EO submission, and any release-tag attempt complete.
|
||||
* @throws DesktopPurgeError when purge submission fails; 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: {
|
||||
latest: { type: 'boolean' },
|
||||
'purge-only': { type: 'boolean' }, 'job-id': { type: 'string' },
|
||||
'purge-only': { type: 'boolean' },
|
||||
'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' },
|
||||
} })
|
||||
const target = positionals[0]
|
||||
@@ -80,8 +80,8 @@ export async function uploadDesktopTarget(args: string[]): Promise<void> {
|
||||
throw new DesktopUploadUsageError('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 DesktopUploadUsageError('desktop upload: --purge-only requires --latest; --job-id requires --purge-only')
|
||||
if (values['purge-only'] && !values.latest) {
|
||||
throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest')
|
||||
}
|
||||
let fileEnvironment: NodeJS.ProcessEnv
|
||||
try {
|
||||
@@ -107,7 +107,7 @@ export async function uploadDesktopTarget(args: string[]): Promise<void> {
|
||||
}
|
||||
const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records')
|
||||
if (values['purge-only']) {
|
||||
await purgeDesktopInstaller(purgeConfig(environment, name), recordsRoot, { ...values['job-id'] !== undefined ? { jobId: values['job-id'] } : {}, credentialLauncher: launcher })
|
||||
await purgeDesktopInstaller(purgeConfig(environment, name))
|
||||
return
|
||||
}
|
||||
const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true })
|
||||
@@ -119,11 +119,8 @@ export async function uploadDesktopTarget(args: string[]): Promise<void> {
|
||||
process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
|
||||
await uploadDesktopRelease(plan, cos, recordsRoot)
|
||||
if (purge !== undefined) {
|
||||
try {
|
||||
await purgeDesktopInstaller(purge, recordsRoot, { credentialLauncher: launcher })
|
||||
} catch (error) {
|
||||
throw new DesktopPurgeError(`desktop upload: installer uploaded; cache refresh incomplete. ${error instanceof DesktopPurgeError ? error.message : 'Inspect the purge record before recovery.'}`)
|
||||
}
|
||||
process.stdout.write('desktop upload: installer uploaded; submitting cache purge.\n')
|
||||
await purgeDesktopInstaller(purge)
|
||||
}
|
||||
if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan)
|
||||
}
|
||||
@@ -166,7 +163,7 @@ export async function runDesktopUploadCommand(args: string[]): Promise<void> {
|
||||
const usage = error instanceof DesktopUploadUsageError
|
||||
|| (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_'))
|
||||
const message = error instanceof DesktopPurgeError ? error.message
|
||||
: usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts <target> [--latest [--purge-only [--job-id <id>]]]; targets: mac-arm64, mac-x64, win-x64.`
|
||||
: usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts <target> [--latest [--purge-only]]; targets: mac-arm64, mac-x64, win-x64.`
|
||||
: '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; these diagnostics contain no SDK details.
|
||||
const output = args.includes('--credential-launcher') ? process.stdout : process.stderr
|
||||
|
||||
@@ -19,9 +19,7 @@ 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.
|
||||
Submit a purge for the production fixed download without uploading. Requires Upload and Latest.
|
||||
#>
|
||||
[CmdletBinding(DefaultParameterSetName = 'Check')]
|
||||
param(
|
||||
@@ -42,10 +40,7 @@ param(
|
||||
[Parameter(ParameterSetName = 'Publish')]
|
||||
[switch]$Latest,
|
||||
[Parameter(ParameterSetName = 'Publish')]
|
||||
[switch]$PurgeOnly,
|
||||
[Parameter(ParameterSetName = 'Publish')]
|
||||
[ValidatePattern('^[a-zA-Z0-9-]{1,128}$')]
|
||||
[string]$JobId
|
||||
[switch]$PurgeOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -58,8 +53,8 @@ $credentials = $null
|
||||
$startInfo = $null
|
||||
$stage = 'decrypt-file'
|
||||
|
||||
if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) {
|
||||
Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production; JobId requires PurgeOnly.'
|
||||
if ($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) {
|
||||
Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -107,9 +102,8 @@ try {
|
||||
$startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket"
|
||||
if ($Latest) { $startInfo.Arguments += " --latest" }
|
||||
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."
|
||||
Write-Output "desktop credentials: submitting a cache purge for $Target in $Environment; no installer upload."
|
||||
} else {
|
||||
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
|
||||
}
|
||||
|
||||
@@ -1,50 +1,15 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { teo } from 'tencentcloud-sdk-nodejs-teo'
|
||||
import { purgeDesktopInstaller, resolveDesktopPurgeConfig, type DesktopPurgeClient } from '../scripts/desktop-edgeone.ts'
|
||||
import { purgeDesktopInstaller, resolveDesktopPurgeConfig } from '../scripts/desktop-edgeone.ts'
|
||||
|
||||
const environment = {
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket',
|
||||
DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key',
|
||||
DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', DOWNLOAD_PROD_EO_TIMEOUT_MS: '30', DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '10',
|
||||
DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture',
|
||||
}
|
||||
const config = resolveDesktopPurgeConfig(environment, 'mac-arm64')
|
||||
const task = { JobId: 'job-1', Target: config.publicUrl, Type: 'purge_url', Status: 'success' }
|
||||
const roots: string[] = []
|
||||
let time = 0
|
||||
|
||||
vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async (ms: number) => { time += ms }) }))
|
||||
|
||||
beforeEach(() => {
|
||||
time = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => time)
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-purge-'))
|
||||
roots.push(root)
|
||||
// Real SDK initialization, with both network operations replaced before use.
|
||||
const client = new teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey } })
|
||||
const create = vi.spyOn(client, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1', RequestId: 'request-1' })
|
||||
const describe = vi.spyOn(client, 'DescribePurgeTasks').mockResolvedValue({ TotalCount: 1, Tasks: [task] })
|
||||
const run = (options: { jobId?: string; credentialLauncher?: boolean } = {}) =>
|
||||
purgeDesktopInstaller(config, root, { ...options, client })
|
||||
const directory = async () => join(root, (await readdir(root))[0]!)
|
||||
const result = async (): Promise<unknown> => JSON.parse(await readFile(join(await directory(), 'result.json'), 'utf8'))
|
||||
return { root, client, create, describe, run, directory, result }
|
||||
}
|
||||
|
||||
function sdkError(code: string) {
|
||||
return Object.assign(new Error('private service details fixture-secret-key'), { code })
|
||||
}
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('production EdgeOne configuration', () => {
|
||||
it.each([
|
||||
@@ -53,8 +18,6 @@ describe('production EdgeOne configuration', () => {
|
||||
[{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u],
|
||||
[{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u],
|
||||
[{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u],
|
||||
[{ DOWNLOAD_PROD_EO_TIMEOUT_MS: '0' }, /positive timer/u],
|
||||
[{ DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '31' }, /must not exceed timeout/u],
|
||||
] as const)('rejects invalid settings %j', (patch, message) => {
|
||||
expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message)
|
||||
})
|
||||
@@ -67,154 +30,50 @@ describe('production EdgeOne configuration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('EdgeOne task tracking', () => {
|
||||
it('preserves SDK HTTP defaults through delayed visibility and processing, and records only safe evidence', async () => {
|
||||
const f = await fixture()
|
||||
const profile = f.client.profile.httpProfile
|
||||
f.describe.mockResolvedValueOnce({ Tasks: [] }).mockResolvedValueOnce({ Tasks: [{ ...task, Status: 'processing' }] })
|
||||
await f.run()
|
||||
expect(f.create).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
|
||||
expect(f.describe).toHaveBeenCalledTimes(3)
|
||||
expect(f.describe).toHaveBeenLastCalledWith({ ZoneId: config.zoneId, Limit: 1000, Filters: [{ Name: 'job-id', Values: ['job-1'] }] })
|
||||
expect(f.client.profile.httpProfile).toBe(profile)
|
||||
expect(profile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 0.01 })
|
||||
expect(await f.result()).toMatchObject({ schemaVersion: 1, success: true, stage: 'complete', jobId: 'job-1' })
|
||||
const plan = await readFile(join(await f.directory(), 'plan.json'), 'utf8')
|
||||
const hash = createHash('sha256').update(await readFile(new URL('../scripts/desktop-edgeone.ts', import.meta.url))).digest('hex')
|
||||
expect(JSON.parse(plan)).toMatchObject({ schemaVersion: 1, sourceSha256: { 'desktop-edgeone.ts': hash } })
|
||||
expect(plan).not.toContain(config.secretId)
|
||||
expect(plan).not.toContain(config.secretKey)
|
||||
describe('EdgeOne purge submission', () => {
|
||||
it('submits once with the pinned SDK and reports acceptance without querying completion', async () => {
|
||||
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask')
|
||||
.mockImplementation(async function (this: InstanceType<typeof teo.v20220901.Client>) {
|
||||
expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 30 })
|
||||
return { JobId: 'job-1' }
|
||||
})
|
||||
const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('must not query'))
|
||||
|
||||
await purgeDesktopInstaller(config)
|
||||
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
|
||||
expect(query).not.toHaveBeenCalled()
|
||||
expect(output).toHaveBeenCalledExactlyOnceWith(
|
||||
`desktop purge: submitted ${config.publicUrl}; task job-1. Cache completion is not checked.\n`,
|
||||
)
|
||||
})
|
||||
|
||||
it('resumes an existing multi-URL job without resubmission', async () => {
|
||||
const f = await fixture()
|
||||
f.describe.mockResolvedValue({ Tasks: [{ ...task, Target: 'https://example.com/other' }, task] })
|
||||
await f.run({ jobId: 'job-1' })
|
||||
expect(f.create).not.toHaveBeenCalled()
|
||||
expect(await f.result()).toMatchObject({ success: true })
|
||||
it('reports a rejected URL without printing the raw response or claiming acceptance', async () => {
|
||||
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const submit = vi.fn().mockResolvedValue({ JobId: 'job-1', FailedList: [{ Targets: [config.publicUrl], Reason: config.secretKey }] })
|
||||
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
|
||||
'desktop purge: EO rejected the URL; inspect EO before submitting again.',
|
||||
)
|
||||
expect(submit).toHaveBeenCalledOnce()
|
||||
expect(output).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['RequestLimitExceeded', 'InternalError.Backend', 'ECONNRESET'])('retries %s queries within the deadline', async (code) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockRejectedValueOnce(sdkError(code))
|
||||
await f.run()
|
||||
expect(f.create).toHaveBeenCalledOnce()
|
||||
expect(f.describe).toHaveBeenCalledTimes(2)
|
||||
it.each(['ECONNRESET', 'UnauthorizedOperation', undefined, config.secretKey])('does not retry or expose SDK errors (%s)', async (code) => {
|
||||
const submit = vi.fn().mockRejectedValue(Object.assign(new Error(config.secretKey), { code }))
|
||||
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
|
||||
`desktop purge: submission not confirmed (${code === undefined || code === config.secretKey ? 'request failed' : code}); check EO task history and teo:CreatePurgeTask permission before submitting again.`,
|
||||
)
|
||||
expect(submit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([undefined, 503])('bounds code-less SDK transport failures with HTTP status %s', async (httpCode) => {
|
||||
const f = await fixture()
|
||||
const error = Object.assign(new Error('private network details'), { getRequestId: () => '', getTraceId: () => '',
|
||||
...httpCode === undefined ? {} : { httpCode } })
|
||||
f.describe.mockRejectedValue(error)
|
||||
await expect(f.run()).rejects.toThrow(/timed out.*--job-id job-1/u)
|
||||
expect(f.describe).toHaveBeenCalledTimes(3)
|
||||
expect(await f.result()).toMatchObject({ success: false })
|
||||
})
|
||||
|
||||
it.each(['AuthFailure', 'UnauthorizedOperation', 'InvalidParameter', 'InvalidParameterValue', 'MissingParameter'])('stops immediately on %s', async (code) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockRejectedValue(sdkError(code))
|
||||
await expect(f.run()).rejects.toThrow(new RegExp(`${code}.*--job-id job-1`, 'u'))
|
||||
expect(f.describe).toHaveBeenCalledOnce()
|
||||
expect(await f.result()).toMatchObject({ success: false })
|
||||
const evidence = await readFile(join(await f.directory(), 'result.json'), 'utf8')
|
||||
expect(evidence).not.toContain('private service details')
|
||||
expect(evidence).not.toContain(config.secretKey)
|
||||
})
|
||||
|
||||
it.each(['failed', 'timeout', 'canceled'])('reports terminal %s with a fresh-purge recovery command', async (status) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockResolvedValue({ Tasks: [{ ...task, Status: status }] })
|
||||
await expect(f.run()).rejects.toThrow('Resolve the EO failure, then pnpm run purge:latest mac-arm64.')
|
||||
expect(f.describe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not poll a rejected submission', async () => {
|
||||
const f = await fixture()
|
||||
f.create.mockResolvedValue({ FailedList: [{ Targets: [config.publicUrl], Reason: 'rejected' }] })
|
||||
await expect(f.run()).rejects.toThrow(/EO rejected.*Resolve the EO failure/u)
|
||||
expect(f.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([{}, { RequestId: config.secretKey }])('reports unknown submission outcome when no job ID is returned', async (response) => {
|
||||
const f = await fixture()
|
||||
f.create.mockResolvedValue(response)
|
||||
await expect(f.run()).rejects.toThrow(/no task ID.*Inspect EO task history/u)
|
||||
expect(f.create).toHaveBeenCalledOnce()
|
||||
expect(f.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never retries a submission with an unknown outcome', async () => {
|
||||
const f = await fixture()
|
||||
f.create.mockRejectedValue(sdkError('ECONNRESET'))
|
||||
await expect(f.run()).rejects.toThrow(/Inspect EO task history/u)
|
||||
expect(f.create).toHaveBeenCalledOnce()
|
||||
expect(f.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ Tasks: [] }, /task was not found/u],
|
||||
[{ Tasks: [{ ...task, Status: 'processing' }] }, /task completion is unknown/u],
|
||||
])('distinguishes invisible tasks from unfinished tasks: %j', async (response, message) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockResolvedValue(response)
|
||||
await expect(f.run({ jobId: 'job-1' })).rejects.toThrow(message)
|
||||
expect(f.describe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ TotalCount: 1001 }, /page limit/u],
|
||||
[{ Tasks: [task, task] }, /multiple entries/u],
|
||||
[{ Tasks: [{ ...task, Target: 'https://example.com/wrong' }] }, /does not contain the expected URL/u],
|
||||
[{ Tasks: [{ ...task, Status: 'unexpected' }] }, /did not succeed/u],
|
||||
])('refuses ambiguous or mismatched task responses: %j', async (response, message) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockResolvedValue(response)
|
||||
await expect(f.run()).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('retains the DPAPI launcher and original identity in recovery instructions', async () => {
|
||||
const f = await fixture()
|
||||
f.describe.mockRejectedValue(sdkError('UnauthorizedOperation'))
|
||||
await expect(f.run({ credentialLauncher: true })).rejects
|
||||
.toThrow(/original -CredentialFile.*-Bucket.*-Upload -Latest -PurgeOnly -JobId job-1/u)
|
||||
})
|
||||
|
||||
it('does not report completion when the final record cannot be saved', async () => {
|
||||
const f = await fixture()
|
||||
f.describe.mockImplementation(async () => {
|
||||
await writeFile(join(await f.directory(), 'result.json'), 'occupied')
|
||||
return { Tasks: [task] }
|
||||
})
|
||||
await expect(f.run()).rejects.toThrow(/cannot save result; inspect EO task job-1/u)
|
||||
const output = vi.spyOn(process.stdout, 'write')
|
||||
expect(output).not.toHaveBeenCalledWith(expect.stringContaining('desktop purge: completed'))
|
||||
})
|
||||
|
||||
it('rejects malformed recovery IDs before allocating a record or contacting EO', async () => {
|
||||
const f = await fixture()
|
||||
await expect(f.run({ jobId: 'unsafe value' })).rejects.toThrow(/invalid --job-id/u)
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
expect(f.create).not.toHaveBeenCalled()
|
||||
expect(f.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lazily initializes the pinned SDK when no client is supplied', async () => {
|
||||
const f = await fixture()
|
||||
vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' })
|
||||
vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockImplementation(async function (this: InstanceType<typeof teo.v20220901.Client>) {
|
||||
expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://' })
|
||||
return { Tasks: [task] }
|
||||
})
|
||||
await purgeDesktopInstaller(config, f.root)
|
||||
expect(await f.result()).toMatchObject({ success: true })
|
||||
})
|
||||
|
||||
it('accepts the minimal caller-owned client', async () => {
|
||||
const f = await fixture()
|
||||
const client: DesktopPurgeClient = { profile: f.client.profile, CreatePurgeTask: f.create, DescribePurgeTasks: f.describe }
|
||||
await purgeDesktopInstaller(config, f.root, { client })
|
||||
expect(await f.result()).toMatchObject({ success: true })
|
||||
it.each([undefined, 'unsafe value', config.secretId])('reports an unconfirmed submission without a usable task ID (%s)', async (jobId) => {
|
||||
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const submit = vi.fn().mockResolvedValue({ JobId: jobId })
|
||||
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
|
||||
'desktop purge: EO returned no task ID; check EO task history before submitting again.',
|
||||
)
|
||||
expect(submit).toHaveBeenCalledOnce()
|
||||
expect(output).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -45,20 +45,19 @@ it('forwards the documented pnpm purge command through both package scripts to t
|
||||
import assert from 'node:assert/strict'
|
||||
export class DesktopPurgeError extends Error {}
|
||||
export const resolveDesktopPurgeConfig = (_env, target) => ({ target })
|
||||
export const purgeDesktopInstaller = async (config, _root, options) => {
|
||||
export const purgeDesktopInstaller = async (config) => {
|
||||
assert.equal(config.target, 'mac-arm64')
|
||||
assert.deepEqual(options, { jobId: 'job-1', credentialLauncher: false })
|
||||
console.log('purge-resume: mac-arm64 job-1')
|
||||
console.log('purge-submit: mac-arm64')
|
||||
}
|
||||
`,
|
||||
}
|
||||
await Promise.all(Object.entries(stubs).map(([name, contents]) => writeFile(join(scripts, name), contents)))
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
|
||||
!/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_|^DOWNLOAD_|^PACKAGE_/iu.test(name)))
|
||||
const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64', '--job-id', 'job-1']
|
||||
const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64']
|
||||
const result = await execute(process.execPath, args, { cwd: directory, env, timeout: 30_000 }).catch((error: unknown) => {
|
||||
expect(error).not.toMatchObject({ killed: true })
|
||||
throw error
|
||||
})
|
||||
expect(result.stdout).toContain('purge-resume: mac-arm64 job-1')
|
||||
expect(result.stdout).toContain('purge-submit: mac-arm64')
|
||||
})
|
||||
@@ -52,7 +52,7 @@ describe('desktop upload command', () => {
|
||||
expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan)
|
||||
if (environment === 'production' && latest) {
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ publicUrl: plan.publicUrl }), expect.any(String), { credentialLauncher: false },
|
||||
expect.objectContaining({ publicUrl: plan.publicUrl }),
|
||||
)
|
||||
expect(vi.mocked(purgeDesktopInstaller).mock.invocationCallOrder[0])
|
||||
.toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!)
|
||||
@@ -75,33 +75,36 @@ const productionEnvironment = {
|
||||
DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-key', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture',
|
||||
}
|
||||
|
||||
describe('cache-only recovery command', () => {
|
||||
describe('cache-only submission command', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment)
|
||||
})
|
||||
|
||||
it.each([undefined, 'job-1'])('refreshes without reading or uploading an installer, job=%s', async (jobId) => {
|
||||
await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64', ...jobId ? ['--job-id', jobId] : []])
|
||||
it('submits a purge without reading or uploading an installer', async () => {
|
||||
await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64'])
|
||||
expect(createDesktopUploadPlan).not.toHaveBeenCalled()
|
||||
expect(uploadDesktopRelease).not.toHaveBeenCalled()
|
||||
expect(tagDesktopRelease).not.toHaveBeenCalled()
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ target: 'mac-arm64' }), expect.any(String), { ...jobId !== undefined ? { jobId } : {}, credentialLauncher: false })
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
publicUrl: 'https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg',
|
||||
}))
|
||||
})
|
||||
|
||||
it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => {
|
||||
it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--latest', '--purge-only', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => {
|
||||
await expect(uploadDesktopTarget(args)).rejects.toThrow()
|
||||
expect(uploadDesktopRelease).not.toHaveBeenCalled()
|
||||
expect(purgeDesktopInstaller).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a completed upload when refresh fails, without tagging or repeating the upload', async () => {
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
vi.mocked(createDesktopUploadPlan).mockResolvedValue({
|
||||
environment: 'production', target: 'mac-arm64', version: '1.2.3', publicUrl: 'https://example.com/new-prefix/installer.dmg',
|
||||
bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', artifacts: [],
|
||||
})
|
||||
vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: task incomplete; resume --job-id job-1'))
|
||||
await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow(/installer uploaded; cache refresh incomplete.*job-1/u)
|
||||
vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: submission not confirmed'))
|
||||
await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow('desktop purge: submission not confirmed')
|
||||
expect(output).toHaveBeenCalledWith('desktop upload: installer uploaded; submitting cache purge.\n')
|
||||
expect(uploadDesktopRelease).toHaveBeenCalledOnce()
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledOnce()
|
||||
expect(tagDesktopRelease).not.toHaveBeenCalled()
|
||||
@@ -121,7 +124,7 @@ describe('cache-only recovery command', () => {
|
||||
expect(launcher ? stdout : stderr).toHaveBeenCalledExactlyOnceWith('desktop purge: safe recovery message\n')
|
||||
expect(launcher ? stderr : stdout).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' }), expect.any(String), expect.objectContaining({ credentialLauncher: launcher }))
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' }))
|
||||
} finally {
|
||||
process.exitCode = previousExitCode
|
||||
vi.unstubAllEnvs()
|
||||
|
||||
@@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1',
|
||||
const roots: string[] = []
|
||||
const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`
|
||||
|
||||
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge-resume' | 'invalid-purge', deployment = 'production') {
|
||||
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge' | 'invalid-purge', deployment = 'production') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-'))
|
||||
roots.push(root)
|
||||
const credentialPath = join(root, 'credential.clixml')
|
||||
let entry = launcher
|
||||
let uploadArguments = ''
|
||||
if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge-resume' || mode === 'invalid-purge') {
|
||||
if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge' || mode === 'invalid-purge') {
|
||||
const scripts = join(root, 'apps/desktop/scripts')
|
||||
await mkdir(scripts, { recursive: true })
|
||||
entry = join(scripts, 'upload-with-credentials.ps1')
|
||||
@@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload
|
||||
assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined)
|
||||
assert.equal(process.env.NODE_OPTIONS, undefined)
|
||||
assert.equal(process.argv[2], 'win-x64')
|
||||
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge-resume' ? ", '--latest', '--purge-only', '--job-id', 'job-1'" : mode === 'upload-latest' ? ", '--latest'" : ''}])
|
||||
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge' ? ", '--latest', '--purge-only'" : mode === 'upload-latest' ? ", '--latest'" : ''}])
|
||||
console.log('fixture-id fixture-secret')
|
||||
console.error('private service details fixture-secret')
|
||||
process.exit(${mode === 'upload-failure' ? 17 : 0})
|
||||
`)
|
||||
uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge-resume' ? ' -Latest -PurgeOnly -JobId job-1' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}`
|
||||
uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge' ? ' -Latest -PurgeOnly' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}`
|
||||
}
|
||||
const setup = mode === 'missing' ? '' : `
|
||||
[pscustomobject]@{
|
||||
@@ -141,7 +141,7 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () =
|
||||
expect(result.output).not.toContain('stage=decrypt-file')
|
||||
})
|
||||
|
||||
it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge-resume'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
|
||||
it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
|
||||
const result = await check(mode)
|
||||
expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0)
|
||||
expect(result.output).toContain('[REDACTED] [REDACTED]')
|
||||
|
||||
Reference in new issue
Block a user