refactor: update URLs and storage configurations to use domestic services
- Changed external links in settings-test.md and package.json to point to the new domestic website (guanzhangai.cn). - Updated electron-builder.yml to publish updates from the new domestic URL. - Introduced a new storage client for Tencent COS and refactored existing R2 client code to support both storage options. - Implemented environment variable checks and configurations for Tencent COS and Cloudflare R2. - Added tests for release storage configuration and ensured proper handling of live publish locks. - Updated telemetry service to use the new official website URL for event reporting.
This commit is contained in:
1 parent
a9960a7dbe
commit
d622985d55
26 files changed
+434
-230
No files matched your search
+13
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
#! ice
|
||||
# AI_LIVE_BASE_URL=https://ice.v.ua
|
||||
# AI_LIVE_API_KEY=REMOVED_HISTORICAL_KEY
|
||||
# AI_LIVE_API_KEY=
|
||||
|
||||
|
||||
AI_LIVE_PRIMARY_MODEL_NAME=deepseek-v4-flash-vision-exp
|
||||
@@ -12,10 +12,20 @@ AI_LIVE_PRIMARY_MODEL_NAME=deepseek-v4-flash-vision-exp
|
||||
LANGSMITH_TRACING=false
|
||||
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
|
||||
LANGSMITH_PROJECT=ncurator
|
||||
LANGSMITH_API_KEY=REMOVED_HISTORICAL_KEY
|
||||
LANGSMITH_API_KEY=
|
||||
LANGCHAIN_CALLBACKS_BACKGROUND=true
|
||||
|
||||
# Release(本地 Windows/macOS 发布,不要提交真实密钥)
|
||||
# Release(国内 COS 为默认发布目标;不要提交真实密钥)
|
||||
RELEASE_STORAGE_TARGET=cos
|
||||
TENCENT_SECRET_ID=
|
||||
TENCENT_SECRET_KEY=
|
||||
COS_BUCKET=ncurator-1324873382
|
||||
COS_REGION=ap-guangzhou
|
||||
COS_PUBLIC_BASE_URL=https://download.guanzhangai.cn
|
||||
# 仅在明确执行真实发布时临时设置,普通打包和 dry-run 必须留空。
|
||||
NCURATOR_ALLOW_LIVE_PUBLISH=
|
||||
|
||||
# 海外 R2 备用发布目标
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_ACCOUNT_ID=
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
provider: generic
|
||||
url: https://download.ncurator.com/updates/latest
|
||||
url: https://download.guanzhangai.cn/updates/latest
|
||||
updaterCacheDirName: ncurator-updater
|
||||
+2
-2
@@ -102,7 +102,7 @@
|
||||
|
||||
#### SET-MODEL-004 免费 AI Key 入口
|
||||
|
||||
- 系统行为:大模型配置页在配置数量与“新增配置”之间展示“免费获取 AI Key”入口,点击后通过系统默认浏览器打开官网免费 Key 页面 `https://www.ncurator.com/zh/free-keys`,当前设置页保持不变。
|
||||
- 系统行为:大模型配置页在配置数量与“新增配置”之间展示“免费获取 AI Key”入口,点击后通过系统默认浏览器打开国内官网免费 Key 页面 `https://www.guanzhangai.cn/zh/free-keys`,当前设置页保持不变。
|
||||
- 验收:
|
||||
- `SET-MODEL-004-AC1` 入口位于配置数量与“新增配置”之间,并与操作区保持对齐。
|
||||
- `SET-MODEL-004-AC2` 点击入口后调用系统外链能力打开官网免费 Key 页面,不改变当前设置页路由。
|
||||
@@ -286,7 +286,7 @@
|
||||
|
||||
#### SET-HELP-001 设置页使用帮助入口
|
||||
|
||||
- 系统行为:设置页左侧栏在“向量模型”下方展示同级、同样式的“使用帮助”入口,点击后通过系统默认浏览器打开官方中文使用手册 `https://www.ncurator.com/zh/manual`,当前设置页保持不变。
|
||||
- 系统行为:设置页左侧栏在“向量模型”下方展示同级、同样式的“使用帮助”入口,点击后通过系统默认浏览器打开国内官网中文使用手册 `https://www.guanzhangai.cn/zh/manual`,当前设置页保持不变。
|
||||
- 验收:
|
||||
- `SET-HELP-001-AC1` “使用帮助”入口紧接在“向量模型”下方,使用与其他设置菜单项一致的样式,并具有“查看使用帮助”的可访问名称。
|
||||
- `SET-HELP-001-AC2` 点击“使用帮助”后调用系统外链能力打开官方中文使用手册,不改变当前设置页路由。
|
||||
|
||||
@@ -295,7 +295,7 @@
|
||||
|
||||
#### WB-COMPOSER-011 使用帮助入口
|
||||
|
||||
- 系统行为:Workbench 左侧栏底部在设置入口旁展示使用帮助按钮,点击后通过系统默认浏览器打开官方中文使用手册 `https://www.ncurator.com/zh/manual`。
|
||||
- 系统行为:Workbench 左侧栏底部在设置入口旁展示使用帮助按钮,点击后通过系统默认浏览器打开国内官网中文使用手册 `https://www.guanzhangai.cn/zh/manual`。
|
||||
- 验收:
|
||||
- `WB-COMPOSER-011-AC1` 使用帮助按钮可见,具有“查看使用帮助”的可访问名称和说明。
|
||||
- `WB-COMPOSER-011-AC2` 点击使用帮助按钮后,系统打开官方中文使用手册,不改变当前会话和输入草稿。
|
||||
|
||||
+50
-19
@@ -1,6 +1,6 @@
|
||||
# 桌面端构建与发布
|
||||
|
||||
本文是 Windows、macOS 构建、正式包回归和 R2 发布的统一说明。当前发布 Windows x64、macOS arm64 和 macOS x64,不发布 Linux。Windows 使用 `windows-vX.Y.Z`,macOS 使用 `mac-vX.Y.Z`,两个平台独立递增和发布。
|
||||
本文是 Windows、macOS 构建、正式包回归和对象存储发布的统一说明。国内默认发布到腾讯云 COS,海外可显式切换到 Cloudflare R2。当前发布 Windows x64、macOS arm64 和 macOS x64,不发布 Linux。Windows 使用 `windows-vX.Y.Z`,macOS 使用 `mac-vX.Y.Z`,两个平台独立递增和发布。
|
||||
|
||||
注意评估改动影响,只回归需要回归的测试用例,避免动不动就全部回归,尤其是在回归一次后,修复的代码并不影响已通过的测试用例时。
|
||||
|
||||
@@ -52,9 +52,9 @@ pnpm release:start -- --platform mac --version X.Y.Z
|
||||
|
||||
### 干净构建与候选包
|
||||
|
||||
prepare 阶段从 Tag 创建独立工作区,执行 `pnpm install --frozen-lockfile`、正式构建、安装包审计和 bundle 固化。它不复用当前源码、旧 `node_modules` 或旧 `dist`,也不上传 R2。
|
||||
prepare 阶段从 Tag 创建独立工作区,执行 `pnpm install --frozen-lockfile`、正式构建、安装包审计和 bundle 固化。它不复用当前源码、旧 `node_modules` 或旧 `dist`,也不上传任何对象存储。
|
||||
|
||||
Windows 的用户可见名称统一为“馆长”:安装包为 `馆长-X.Y.Z-Windows-x64.exe`,安装后的主程序为 `馆长.exe`,快捷方式与卸载项也使用“馆长”。npm 包名、`appId`、`.ncurator` 目录和 R2 对象键等内部技术标识保持不变。
|
||||
Windows 的用户可见名称统一为“馆长”:安装包为 `馆长-X.Y.Z-Windows-x64.exe`,安装后的主程序为 `馆长.exe`,快捷方式与卸载项也使用“馆长”。npm 包名、`appId`、`.ncurator` 目录和对象键等内部技术标识保持不变。
|
||||
|
||||
候选包保存在:
|
||||
|
||||
@@ -116,6 +116,14 @@ pnpm release:mac -- --tag mac-vX.Y.Z --publish-only
|
||||
pnpm release:status -- --tag <windows-vX.Y.Z|mac-vX.Y.Z>
|
||||
```
|
||||
|
||||
真实发布默认锁定。只有用户明确批准上线后,才可在单次发布终端中设置:
|
||||
|
||||
```bash
|
||||
export NCURATOR_ALLOW_LIVE_PUBLISH=publish-to-production
|
||||
```
|
||||
|
||||
普通构建、候选包测试和 `pnpm release:storage:plan` 都不需要这个变量。未设置时,`--publish-only`、上传脚本和 finalize 都会在发起网络写入前失败。
|
||||
|
||||
## 2. 构建与平台
|
||||
|
||||
### Windows
|
||||
@@ -132,9 +140,9 @@ Windows 本地发布环境:
|
||||
|
||||
- Windows x64、Node.js 22 x64、项目指定版本的 pnpm;
|
||||
- Python 3.12 x64、Git、tar;
|
||||
- 发布到 R2 时还需要 AWS CLI 和 R2 配置。
|
||||
- 发布到 COS 或 R2 时还需要 AWS CLI 和对应存储配置。
|
||||
|
||||
`pnpm release:doctor` 当前检查 Windows 本地发布与 R2 访问条件。
|
||||
`pnpm release:doctor` 检查 Windows 本地发布与当前存储目标的访问条件。
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -184,7 +192,7 @@ release-stage/
|
||||
|
||||
## 3. 发布与维护
|
||||
|
||||
### R2 与公开通道
|
||||
### COS、R2 与公开通道
|
||||
|
||||
Windows 和 macOS 使用独立目录、latest metadata 和固定下载地址:
|
||||
|
||||
@@ -203,9 +211,20 @@ downloads/latest/macos-arm64.dmg
|
||||
downloads/latest/macos-x64.dmg
|
||||
```
|
||||
|
||||
版本化产物使用不可变缓存;latest、manifest 和固定下载使用 `no-store`。fragment 中的 `name` 记录用户可见文件名,`storageName` 记录 R2 ASCII 对象名;二者共同进入候选 bundle 校验。Windows 固定下载与 manifest 返回 `馆长-X.Y.Z-Windows-x64.exe`,自动更新 metadata 则指向对应的 ASCII 对象键。
|
||||
版本化产物使用不可变缓存;latest、manifest 和固定下载使用 `no-store`。fragment 中的 `name` 记录用户可见文件名,`storageName` 记录对象存储的 ASCII 对象名;二者共同进入候选 bundle 校验。Windows 固定下载与 manifest 返回 `馆长-X.Y.Z-Windows-x64.exe`,自动更新 metadata 则指向对应的 ASCII 对象键。
|
||||
|
||||
本地 `.env` 使用:
|
||||
国内默认目标使用:
|
||||
|
||||
```text
|
||||
RELEASE_STORAGE_TARGET=cos
|
||||
TENCENT_SECRET_ID
|
||||
TENCENT_SECRET_KEY
|
||||
COS_BUCKET=ncurator-1324873382
|
||||
COS_REGION=ap-guangzhou
|
||||
COS_PUBLIC_BASE_URL=https://download.guanzhangai.cn
|
||||
```
|
||||
|
||||
海外发布时显式设置 `RELEASE_STORAGE_TARGET=r2`,并使用:
|
||||
|
||||
```text
|
||||
R2_ACCESS_KEY_ID
|
||||
@@ -215,9 +234,20 @@ R2_BUCKET_NAME
|
||||
R2_PUBLIC_BASE_URL=https://download.ncurator.com
|
||||
```
|
||||
|
||||
腾讯云侧固定配置:COS 桶为 `ncurator-1324873382`、地域为广州;`download.guanzhangai.cn` 在备案通过后接入腾讯云 CDN,CDN 回源该私有桶。发布子用户使用 [最小 COS 权限模板](../scripts/release/cos/cam-policy.json),不授予账号管理、域名、DNS 或其他桶权限。CDN 刷新权限后续单独配置,不混入上传密钥。
|
||||
|
||||
官网与桌面端的国内地址固定为:
|
||||
|
||||
```text
|
||||
官网:https://www.guanzhangai.cn
|
||||
下载:https://download.guanzhangai.cn
|
||||
```
|
||||
|
||||
海外官网 `www.ncurator.com` 仍通过 Cloudflare 访问,官网下载 API 会按请求域名选择国内 COS 或海外 R2。桌面端发行包统一使用国内官网与国内更新通道。
|
||||
|
||||
publish 会上传候选产物和 fragment,更新对应平台的自动更新 metadata、官网 manifest 与固定下载地址,然后验证公开文件大小、sha512、文件名和 manifest。公开验证成功后才把候选状态标记为 `published`;随后清理本地 `tests/logs/**`,不在失败、prepare-only 或 test-only 阶段清理。
|
||||
|
||||
公开验证成功后会清理该平台更早版本的 artifacts 与 release-status,R2 只保留当前最新版本。
|
||||
公开验证成功后会清理该平台更早版本的 artifacts 与 release-status,当前发布目标只保留最新版本。
|
||||
|
||||
### 本地保留与清理
|
||||
|
||||
@@ -228,13 +258,13 @@ prepare 和 test 的临时代码、依赖与构建目录位于 `.ncurator/releas
|
||||
### 自动更新与下载
|
||||
|
||||
```text
|
||||
Windows 清单:https://download.ncurator.com/releases/latest/windows.json
|
||||
macOS 清单:https://download.ncurator.com/releases/latest/macos.json
|
||||
Windows 下载:https://download.ncurator.com/downloads/latest/windows-x64.exe
|
||||
macOS arm64:https://download.ncurator.com/downloads/latest/macos-arm64.dmg
|
||||
macOS x64:https://download.ncurator.com/downloads/latest/macos-x64.dmg
|
||||
Windows 更新:https://download.ncurator.com/updates/latest/latest.yml
|
||||
macOS 更新:https://download.ncurator.com/updates/latest/latest-mac.yml
|
||||
Windows 清单:https://download.guanzhangai.cn/releases/latest/windows.json
|
||||
macOS 清单:https://download.guanzhangai.cn/releases/latest/macos.json
|
||||
Windows 下载:https://download.guanzhangai.cn/downloads/latest/windows-x64.exe
|
||||
macOS arm64:https://download.guanzhangai.cn/downloads/latest/macos-arm64.dmg
|
||||
macOS x64:https://download.guanzhangai.cn/downloads/latest/macos-x64.dmg
|
||||
Windows 更新:https://download.guanzhangai.cn/updates/latest/latest.yml
|
||||
macOS 更新:https://download.guanzhangai.cn/updates/latest/latest-mac.yml
|
||||
```
|
||||
|
||||
应用启动 5 秒后静默检查更新,持续运行时每 4 小时检查一次。发现新版本后由用户主动下载;下载完成后可以立即重启或稍后安装。
|
||||
@@ -253,10 +283,10 @@ Windows 更新采用“先完成应用关闭、再启动安装器”的时序:
|
||||
|
||||
- Tag 已创建但 prepare 中断:基于原 Tag 重新执行 `--prepare-only`。
|
||||
- 候选包已生成但测试中断:基于原候选包重新执行 `--test-only`。
|
||||
- 上传或 finalize 中断:基于原候选包恢复 publish,或使用 `release:r2:finalize`。
|
||||
- 上传或 finalize 中断:基于原候选包恢复 publish,或使用 `release:storage:finalize`。
|
||||
- 已发布版本存在问题:通常发布更高版本修复包。回退下载通道不会让已安装客户端自动降级。
|
||||
|
||||
同一个 Tag 的 R2 对象如果已存在但哈希不同,禁止覆盖。恢复过程中也不能重新构建并替换候选包。
|
||||
同一个 Tag 的对象如果已存在但哈希不同,禁止覆盖。恢复过程中也不能重新构建并替换候选包。
|
||||
|
||||
## 4. 命令参考
|
||||
|
||||
@@ -297,7 +327,8 @@ pnpm test:e2e:package -- --bundle <release-bundle.json> --case <caseId-1> --case
|
||||
|
||||
```bash
|
||||
pnpm release:status -- --tag <windows-vX.Y.Z|mac-vX.Y.Z>
|
||||
pnpm release:r2:finalize -- --tag <windows-vX.Y.Z|mac-vX.Y.Z>
|
||||
pnpm release:storage:plan
|
||||
pnpm release:storage:finalize -- --tag <windows-vX.Y.Z|mac-vX.Y.Z>
|
||||
```
|
||||
|
||||
常见失败处理:构建或审计失败时先确认真实运行依赖,不直接放宽包规则;macOS 签名或启动失败时停止该架构发布;正式包 E2E 失败时读取 package summary 和对应 case raw logs;公开 metadata、下载或哈希验证失败时停止更新 latest 通道。
|
||||
@@ -53,7 +53,7 @@ Settings 顶层 suite 对齐 PRD 模块:
|
||||
- What:大模型配置页展示免费 AI Key 官网入口,并保持与配置操作区的正确位置关系。
|
||||
- Why:用户需要在新增配置前快速获得可体验的 Key,同时外链操作不能破坏当前设置页状态。
|
||||
- How:进入大模型配置页,检查入口位于配置数量与“新增配置”之间,点击后收集真实 Renderer → Preload 外链调用信号。
|
||||
- Oracle:入口位置和可访问名称符合 `SET-MODEL-004-AC1`;外链目标为 `https://www.ncurator.com/zh/free-keys` 且当前路由不变,对齐 `SET-MODEL-004-AC2`。
|
||||
- Oracle:入口位置和可访问名称符合 `SET-MODEL-004-AC1`;外链目标为 `https://www.guanzhangai.cn/zh/free-keys` 且当前路由不变,对齐 `SET-MODEL-004-AC2`。
|
||||
- Not:不访问外部网页内容,也不验证免费 Key 的实时可用性。
|
||||
|
||||
#### `model-delete-config-feedback`
|
||||
|
||||
@@ -74,4 +74,4 @@ dmg:
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://download.ncurator.com/updates/latest
|
||||
url: https://download.guanzhangai.cn/updates/latest
|
||||
+3
-1
@@ -4,7 +4,7 @@
|
||||
"description": "Local-first AI workspace for documents, research, and task execution",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "NCurator",
|
||||
"homepage": "https://www.ncurator.com",
|
||||
"homepage": "https://www.guanzhangai.cn",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
@@ -34,8 +34,10 @@
|
||||
"release:win": "node scripts/release/windows.mjs",
|
||||
"release:doctor": "node scripts/release/doctor.mjs",
|
||||
"release:status": "node scripts/release/status.mjs",
|
||||
"release:storage:plan": "node scripts/release/storage/plan.mjs",
|
||||
"release:signing:setup": "node scripts/release/setup-macos-signing.mjs",
|
||||
"release:mac": "node scripts/release/macos.mjs",
|
||||
"release:storage:finalize": "node scripts/release/r2/finalize.mjs",
|
||||
"release:r2:finalize": "node scripts/release/r2/finalize.mjs",
|
||||
"test:workbench": "node scripts/test/workflow.mjs --domain workbench --type e2e --target source",
|
||||
"test:workbench:e2e": "node scripts/test/workflow.mjs --domain workbench --type e2e --target source",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"statement": [
|
||||
{
|
||||
"effect": "allow",
|
||||
"action": [
|
||||
"name/cos:GetBucket",
|
||||
"name/cos:HeadObject",
|
||||
"name/cos:GetObject",
|
||||
"name/cos:PutObject",
|
||||
"name/cos:DeleteObject",
|
||||
"name/cos:InitiateMultipartUpload",
|
||||
"name/cos:ListMultipartUploads",
|
||||
"name/cos:ListParts",
|
||||
"name/cos:UploadPart",
|
||||
"name/cos:CompleteMultipartUpload",
|
||||
"name/cos:AbortMultipartUpload"
|
||||
],
|
||||
"resource": ["qcs::cos:ap-guangzhou:uid/1324873382:ncurator-1324873382/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { loadReleaseEnvironment } from './shared/environment.mjs'
|
||||
import { inspectLocalWindowsRelease } from './shared/windows.mjs'
|
||||
import { resolveReleaseStorageConfig } from './storage/config.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const ROOT = process.cwd()
|
||||
@@ -22,14 +23,24 @@ if (!inspection.ready) {
|
||||
process.stdout.write('- pnpm configured version: ready\n')
|
||||
process.stdout.write('- AWS CLI: ready\n')
|
||||
|
||||
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
|
||||
const bucket = String(process.env.R2_BUCKET_NAME).replace(/^\/+|\/+$/gu, '')
|
||||
const storage = resolveReleaseStorageConfig()
|
||||
await execFileAsync(
|
||||
inspection.awsExecutable,
|
||||
['s3api', 'head-bucket', '--bucket', bucket, '--endpoint-url', endpoint, '--region', 'auto'],
|
||||
{ maxBuffer: 10 * 1024 * 1024 }
|
||||
[
|
||||
's3api',
|
||||
'head-bucket',
|
||||
'--bucket',
|
||||
storage.bucket,
|
||||
'--endpoint-url',
|
||||
storage.endpoint,
|
||||
'--region',
|
||||
storage.region
|
||||
],
|
||||
{ env: { ...process.env, ...storage.credentials }, maxBuffer: 10 * 1024 * 1024 }
|
||||
).catch((error) => {
|
||||
throw new Error(`R2 access check failed: ${String(error?.stderr || error?.message || error)}`)
|
||||
throw new Error(
|
||||
`${storage.label} access check failed: ${String(error?.stderr || error?.message || error)}`
|
||||
)
|
||||
})
|
||||
process.stdout.write('- Cloudflare R2 access: ready\n')
|
||||
process.stdout.write(`- ${storage.label} access: ready\n`)
|
||||
}
|
||||
+10
-17
@@ -18,6 +18,7 @@ import { createReleaseStageDeadline } from './shared/stage-deadline.mjs'
|
||||
import { prepareReleaseWorkspace } from './workspace.mjs'
|
||||
import { parseReleaseTag } from './tag.mjs'
|
||||
import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs'
|
||||
import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './storage/config.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const ROOT = process.cwd()
|
||||
@@ -56,6 +57,7 @@ if (!releaseVersion || modes.length !== 1) {
|
||||
)
|
||||
}
|
||||
const mode = modes[0][0]
|
||||
if (mode === 'publish') assertLivePublishAllowed()
|
||||
if (includeX64E2E && mode !== 'test') {
|
||||
throw new Error('--include-x64-e2e is only valid with --test-only')
|
||||
}
|
||||
@@ -113,22 +115,7 @@ const validateEnvironment = async () => {
|
||||
}
|
||||
|
||||
if (mode === 'publish') {
|
||||
for (const name of [
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_BUCKET_NAME',
|
||||
'R2_PUBLIC_BASE_URL'
|
||||
]) {
|
||||
if (!String(process.env[name] || '').trim()) {
|
||||
throw new Error(`missing environment variable: ${name}`)
|
||||
}
|
||||
}
|
||||
if (
|
||||
String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '') !== 'https://download.ncurator.com'
|
||||
) {
|
||||
throw new Error('R2_PUBLIC_BASE_URL must be https://download.ncurator.com')
|
||||
}
|
||||
resolveReleaseStorageConfig()
|
||||
const awsExecutable =
|
||||
String(process.env.NCURATOR_AWS_CLI || '').trim() ||
|
||||
path.join(defaultToolRoot, 'awscli', 'bin', 'aws')
|
||||
@@ -227,7 +214,13 @@ const sanitizedTestEnvironment = () => {
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_BUCKET_NAME',
|
||||
'R2_PUBLIC_BASE_URL'
|
||||
'R2_PUBLIC_BASE_URL',
|
||||
'TENCENT_SECRET_ID',
|
||||
'TENCENT_SECRET_KEY',
|
||||
'COS_BUCKET',
|
||||
'COS_REGION',
|
||||
'COS_PUBLIC_BASE_URL',
|
||||
'NCURATOR_ALLOW_LIVE_PUBLISH'
|
||||
]) {
|
||||
delete environment[name]
|
||||
}
|
||||
|
||||
@@ -1,71 +1,3 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { loadReleaseEnvironment } from '../shared/environment.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export const createR2Client = async (root = process.cwd()) => {
|
||||
await loadReleaseEnvironment(root)
|
||||
for (const name of [
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_BUCKET_NAME',
|
||||
'R2_PUBLIC_BASE_URL'
|
||||
]) {
|
||||
if (!String(process.env[name] || '').trim())
|
||||
throw new Error(`missing environment variable: ${name}`)
|
||||
}
|
||||
const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '')
|
||||
if (publicBaseUrl !== 'https://download.ncurator.com') {
|
||||
throw new Error(`unexpected R2 public URL: ${publicBaseUrl}`)
|
||||
}
|
||||
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
|
||||
const bucket = String(process.env.R2_BUCKET_NAME).replace(/^\/+|\/+$/gu, '')
|
||||
const localAws = path.join(os.homedir(), '.ncurator', 'release-tools', 'awscli', 'bin', 'aws')
|
||||
const windowsAws = process.env.ProgramFiles
|
||||
? path.join(process.env.ProgramFiles, 'Amazon', 'AWSCLIV2', 'aws.exe')
|
||||
: ''
|
||||
const awsExecutable =
|
||||
String(process.env.NCURATOR_AWS_CLI || '').trim() ||
|
||||
(existsSync(localAws) ? localAws : existsSync(windowsAws) ? windowsAws : 'aws')
|
||||
const aws = (args) =>
|
||||
execFileAsync(awsExecutable, [...args, '--endpoint-url', endpoint, '--region', 'auto'], {
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
|
||||
const listKeys = async (prefix) => {
|
||||
const keys = []
|
||||
let continuationToken
|
||||
do {
|
||||
const args = [
|
||||
's3api',
|
||||
'list-objects-v2',
|
||||
'--bucket',
|
||||
bucket,
|
||||
'--prefix',
|
||||
prefix,
|
||||
'--output',
|
||||
'json'
|
||||
]
|
||||
if (continuationToken) args.push('--continuation-token', continuationToken)
|
||||
const page = JSON.parse((await aws(args)).stdout)
|
||||
keys.push(...(page.Contents || []).map((entry) => entry.Key))
|
||||
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined
|
||||
} while (continuationToken)
|
||||
return keys
|
||||
}
|
||||
|
||||
const deleteKeys = async (keys) => {
|
||||
for (const key of [...new Set(keys)]) {
|
||||
await aws(['s3api', 'delete-object', '--bucket', bucket, '--key', key])
|
||||
process.stdout.write(`[release] removed old R2 object ${key}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
return { aws, bucket, publicBaseUrl, listKeys, deleteKeys }
|
||||
}
|
||||
// Compatibility entry point. Shared release metadata remains under r2/, while the
|
||||
// storage client now supports Tencent COS (default) and Cloudflare R2.
|
||||
export { createReleaseStorageClient as createR2Client } from '../storage/client.mjs'
|
||||
@@ -6,7 +6,7 @@ import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { assertReleaseFragment } from './fragment-contract.mjs'
|
||||
import { fetchWithRetry } from '../shared/http.mjs'
|
||||
import { createR2Client } from './client.mjs'
|
||||
import { createReleaseStorageClient } from '../storage/client.mjs'
|
||||
import { artifactKey, fixedDownloadKey, statusKey } from './storage-layout.mjs'
|
||||
import { parseReleaseTag } from '../tag.mjs'
|
||||
|
||||
@@ -22,7 +22,8 @@ try {
|
||||
throw new Error('usage: node scripts/release/r2/finalize-macos.mjs --tag <mac-v1.2.3>')
|
||||
}
|
||||
|
||||
const { aws, bucket, publicBaseUrl, listKeys, deleteKeys } = await createR2Client(ROOT)
|
||||
const { aws, bucket, publicBaseUrl, listKeys, deleteKeys, label } =
|
||||
await createReleaseStorageClient(ROOT)
|
||||
const workDirectory = await mkdtemp(path.join(os.tmpdir(), `ncurator-macos-${releaseTag}-`))
|
||||
|
||||
const downloadAndVerifyFragment = async (platform) => {
|
||||
@@ -43,7 +44,7 @@ const downloadAndVerifyFragment = async (platform) => {
|
||||
.stdout
|
||||
)
|
||||
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
|
||||
throw new Error(`R2 object does not match release fragment: ${key}`)
|
||||
throw new Error(`${label} object does not match release fragment: ${key}`)
|
||||
}
|
||||
}
|
||||
return fragment
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { assertReleaseFragment } from './fragment-contract.mjs'
|
||||
import { artifactKey, fixedDownloadKey, statusKey } from './storage-layout.mjs'
|
||||
import { createR2Client } from './client.mjs'
|
||||
import { createReleaseStorageClient } from '../storage/client.mjs'
|
||||
import { fetchWithRetry } from '../shared/http.mjs'
|
||||
import { parseReleaseTag } from '../tag.mjs'
|
||||
|
||||
@@ -18,7 +18,8 @@ try {
|
||||
throw new Error('usage: node scripts/release/r2/finalize-windows.mjs --tag <windows-v1.2.3>')
|
||||
}
|
||||
|
||||
const { aws, bucket, publicBaseUrl, listKeys, deleteKeys } = await createR2Client(ROOT)
|
||||
const { aws, bucket, publicBaseUrl, listKeys, deleteKeys, label } =
|
||||
await createReleaseStorageClient(ROOT)
|
||||
const workDirectory = await mkdtemp(path.join(os.tmpdir(), `ncurator-windows-${releaseTag}-`))
|
||||
|
||||
try {
|
||||
@@ -39,7 +40,7 @@ try {
|
||||
.stdout
|
||||
)
|
||||
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
|
||||
throw new Error(`R2 object does not match release fragment: ${key}`)
|
||||
throw new Error(`${label} object does not match release fragment: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { loadReleaseEnvironment } from '../shared/environment.mjs'
|
||||
import { assertReleaseFragment } from './fragment-contract.mjs'
|
||||
import { fetchWithRetry } from '../shared/http.mjs'
|
||||
import { artifactKey, statusKey } from './storage-layout.mjs'
|
||||
import { createReleaseStorageClient } from '../storage/client.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
await loadReleaseEnvironment(process.cwd())
|
||||
const [inputDirectory = '', fragmentPath = ''] = process.argv.slice(2)
|
||||
|
||||
const requiredEnvironment = [
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_BUCKET_NAME',
|
||||
'R2_PUBLIC_BASE_URL'
|
||||
]
|
||||
for (const name of requiredEnvironment) {
|
||||
if (!String(process.env[name] || '').trim())
|
||||
throw new Error(`missing environment variable: ${name}`)
|
||||
}
|
||||
|
||||
if (!inputDirectory || !fragmentPath) {
|
||||
throw new Error('usage: node scripts/release/r2/upload-fragment.mjs <input-dir> <fragment-json>')
|
||||
}
|
||||
@@ -33,17 +15,7 @@ if (!inputDirectory || !fragmentPath) {
|
||||
const fragment = JSON.parse(await readFile(fragmentPath, 'utf8'))
|
||||
assertReleaseFragment(fragment)
|
||||
|
||||
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
|
||||
const bucket = String(process.env.R2_BUCKET_NAME).replace(/^\/+|\/+$/gu, '')
|
||||
const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '')
|
||||
const localAws = path.join(os.homedir(), '.ncurator', 'release-tools', 'awscli', 'bin', 'aws')
|
||||
const awsExecutable =
|
||||
String(process.env.NCURATOR_AWS_CLI || '').trim() || (existsSync(localAws) ? localAws : 'aws')
|
||||
|
||||
const aws = async (args) =>
|
||||
execFileAsync(awsExecutable, [...args, '--endpoint-url', endpoint, '--region', 'auto'], {
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
})
|
||||
const { aws, bucket, publicBaseUrl, label } = await createReleaseStorageClient()
|
||||
|
||||
const contentType = (name) => {
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
@@ -99,9 +71,9 @@ for (const file of fragment.files) {
|
||||
}
|
||||
|
||||
const remote = await readRemoteObject(key)
|
||||
if (!remote) throw new Error(`R2 object disappeared after upload: ${key}`)
|
||||
if (!remote) throw new Error(`${label} object disappeared after upload: ${key}`)
|
||||
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
|
||||
throw new Error(`R2 verification failed: ${key}`)
|
||||
throw new Error(`${label} verification failed: ${key}`)
|
||||
}
|
||||
|
||||
const publicResponse = await fetchWithRetry(`${publicBaseUrl}/${key}`, {
|
||||
@@ -109,7 +81,7 @@ for (const file of fragment.files) {
|
||||
cache: 'no-store'
|
||||
})
|
||||
if (!publicResponse.ok || Number(publicResponse.headers.get('content-length')) !== file.size) {
|
||||
throw new Error(`public R2 verification failed: ${key}`)
|
||||
throw new Error(`public ${label} verification failed: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,13 +25,6 @@ export const loadReleaseEnvironment = async (root) => {
|
||||
if (!process.env[name]) process.env[name] = value
|
||||
}
|
||||
|
||||
if (!process.env.AWS_ACCESS_KEY_ID && process.env.R2_ACCESS_KEY_ID) {
|
||||
process.env.AWS_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID
|
||||
}
|
||||
if (!process.env.AWS_SECRET_ACCESS_KEY && process.env.R2_SECRET_ACCESS_KEY) {
|
||||
process.env.AWS_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && !process.env.NCURATOR_AWS_CLI) {
|
||||
const candidates = [
|
||||
process.env.ProgramFiles
|
||||
|
||||
@@ -8,10 +8,11 @@ import { loadReleaseEnvironment } from './environment.mjs'
|
||||
import { assertReleaseFragment } from '../r2/fragment-contract.mjs'
|
||||
import { fetchWithRetry } from './http.mjs'
|
||||
import { artifactKey, statusKey } from '../r2/storage-layout.mjs'
|
||||
import { DOMESTIC_DOWNLOAD_BASE_URL, resolveReleaseStorageConfig } from '../storage/config.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const WINDOWS_PLATFORM = 'windows-x64'
|
||||
const PUBLIC_RELEASE_BASE_URL = 'https://download.ncurator.com'
|
||||
const PUBLIC_RELEASE_BASE_URL = DOMESTIC_DOWNLOAD_BASE_URL
|
||||
|
||||
const commandResult = async (command, args, options = {}) => {
|
||||
try {
|
||||
@@ -139,20 +140,10 @@ export const inspectLocalWindowsRelease = async (root, { requirePublish = true }
|
||||
}
|
||||
|
||||
if (requirePublish) {
|
||||
for (const name of [
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_BUCKET_NAME',
|
||||
'R2_PUBLIC_BASE_URL'
|
||||
]) {
|
||||
if (!String(process.env[name] || '').trim()) {
|
||||
issues.push(`missing environment variable: ${name}`)
|
||||
}
|
||||
}
|
||||
const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL || '').replace(/\/$/u, '')
|
||||
if (publicBaseUrl && publicBaseUrl !== PUBLIC_RELEASE_BASE_URL) {
|
||||
issues.push(`R2_PUBLIC_BASE_URL must be ${PUBLIC_RELEASE_BASE_URL}`)
|
||||
try {
|
||||
resolveReleaseStorageConfig()
|
||||
} catch (error) {
|
||||
issues.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,42 +183,46 @@ export const verifyPublicReleaseFragment = async (
|
||||
publicBaseUrl = PUBLIC_RELEASE_BASE_URL
|
||||
) => {
|
||||
for (const file of fragment.files) {
|
||||
const response = await fetchWithRetry(`${publicBaseUrl}/${artifactKey(fragment, file.storageName)}`, {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store'
|
||||
})
|
||||
const response = await fetchWithRetry(
|
||||
`${publicBaseUrl}/${artifactKey(fragment, file.storageName)}`,
|
||||
{
|
||||
method: 'HEAD',
|
||||
cache: 'no-store'
|
||||
}
|
||||
)
|
||||
if (!response.ok || Number(response.headers.get('content-length')) !== file.size) {
|
||||
throw new Error(`remote release asset verification failed: ${file.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const verifyR2ReleaseFragment = async (fragment, awsExecutable) => {
|
||||
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
|
||||
const bucket = String(process.env.R2_BUCKET_NAME || '').replace(/^\/+|\/+$/gu, '')
|
||||
if (!awsExecutable || !bucket || !process.env.R2_ACCOUNT_ID) {
|
||||
throw new Error('R2 verification configuration is incomplete')
|
||||
}
|
||||
export const verifyReleaseStorageFragment = async (fragment, awsExecutable) => {
|
||||
const config = resolveReleaseStorageConfig()
|
||||
if (!awsExecutable) throw new Error(`${config.label} verification configuration is incomplete`)
|
||||
for (const file of fragment.files) {
|
||||
const key = artifactKey(fragment, file.storageName)
|
||||
const result = await commandResult(awsExecutable, [
|
||||
's3api',
|
||||
'head-object',
|
||||
'--bucket',
|
||||
bucket,
|
||||
'--key',
|
||||
key,
|
||||
'--output',
|
||||
'json',
|
||||
'--endpoint-url',
|
||||
endpoint,
|
||||
'--region',
|
||||
'auto'
|
||||
])
|
||||
if (!result.ok) throw new Error(`R2 release asset lookup failed: ${key}`)
|
||||
const result = await commandResult(
|
||||
awsExecutable,
|
||||
[
|
||||
's3api',
|
||||
'head-object',
|
||||
'--bucket',
|
||||
config.bucket,
|
||||
'--key',
|
||||
key,
|
||||
'--output',
|
||||
'json',
|
||||
'--endpoint-url',
|
||||
config.endpoint,
|
||||
'--region',
|
||||
config.region
|
||||
],
|
||||
{ env: { ...process.env, ...config.credentials } }
|
||||
)
|
||||
if (!result.ok) throw new Error(`${config.label} release asset lookup failed: ${key}`)
|
||||
const remote = JSON.parse(result.stdout)
|
||||
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
|
||||
throw new Error(`R2 release asset does not match fragment: ${key}`)
|
||||
throw new Error(`${config.label} release asset does not match fragment: ${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
readPublicReleaseFragment,
|
||||
resolveAwsExecutable,
|
||||
verifyPublicReleaseFragment,
|
||||
verifyR2ReleaseFragment
|
||||
verifyReleaseStorageFragment
|
||||
} from './shared/windows.mjs'
|
||||
import { parseReleaseTag } from './tag.mjs'
|
||||
import { resolveReleaseStorageConfig } from './storage/config.mjs'
|
||||
|
||||
const ROOT = process.cwd()
|
||||
await loadReleaseEnvironment(ROOT)
|
||||
@@ -22,10 +23,12 @@ try {
|
||||
throw new Error('usage: node scripts/release/status.mjs --tag <mac-v1.2.3|windows-v1.2.3>')
|
||||
}
|
||||
|
||||
const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL || PUBLIC_RELEASE_BASE_URL).replace(
|
||||
/\/$/u,
|
||||
''
|
||||
)
|
||||
let publicBaseUrl = PUBLIC_RELEASE_BASE_URL
|
||||
try {
|
||||
publicBaseUrl = resolveReleaseStorageConfig().publicBaseUrl
|
||||
} catch {
|
||||
// Public status checks can still run against the domestic channel without local credentials.
|
||||
}
|
||||
const platforms = releaseFamily === 'mac' ? ['macos-arm64', 'macos-x64'] : ['windows-x64']
|
||||
const awsExecutable = await resolveAwsExecutable()
|
||||
if (!awsExecutable) throw new Error('AWS CLI is required to verify release sha512 metadata')
|
||||
@@ -36,7 +39,7 @@ for (const platform of platforms) {
|
||||
continue
|
||||
}
|
||||
await verifyPublicReleaseFragment(fragment, publicBaseUrl)
|
||||
await verifyR2ReleaseFragment(fragment, awsExecutable)
|
||||
await verifyReleaseStorageFragment(fragment, awsExecutable)
|
||||
const application = fragment.audit.applications[0]
|
||||
const installedMiB = (Number(application.totalBytes || 0) / 1024 / 1024).toFixed(1)
|
||||
process.stdout.write(`${platform.padEnd(14)} ready, audited, installed ${installedMiB} MiB\n`)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { loadReleaseEnvironment } from '../shared/environment.mjs'
|
||||
import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './config.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export const createReleaseStorageClient = async (root = process.cwd()) => {
|
||||
await loadReleaseEnvironment(root)
|
||||
assertLivePublishAllowed()
|
||||
const config = resolveReleaseStorageConfig()
|
||||
const localAws = path.join(os.homedir(), '.ncurator', 'release-tools', 'awscli', 'bin', 'aws')
|
||||
const windowsAws = process.env.ProgramFiles
|
||||
? path.join(process.env.ProgramFiles, 'Amazon', 'AWSCLIV2', 'aws.exe')
|
||||
: ''
|
||||
const awsExecutable =
|
||||
String(process.env.NCURATOR_AWS_CLI || '').trim() ||
|
||||
(existsSync(localAws) ? localAws : existsSync(windowsAws) ? windowsAws : 'aws')
|
||||
const aws = (args) =>
|
||||
execFileAsync(
|
||||
awsExecutable,
|
||||
[...args, '--endpoint-url', config.endpoint, '--region', config.region],
|
||||
{
|
||||
env: { ...process.env, ...config.credentials },
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
}
|
||||
)
|
||||
|
||||
const listKeys = async (prefix) => {
|
||||
const keys = []
|
||||
let continuationToken
|
||||
do {
|
||||
const args = [
|
||||
's3api',
|
||||
'list-objects-v2',
|
||||
'--bucket',
|
||||
config.bucket,
|
||||
'--prefix',
|
||||
prefix,
|
||||
'--output',
|
||||
'json'
|
||||
]
|
||||
if (continuationToken) args.push('--continuation-token', continuationToken)
|
||||
const page = JSON.parse((await aws(args)).stdout)
|
||||
keys.push(...(page.Contents || []).map((entry) => entry.Key))
|
||||
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined
|
||||
} while (continuationToken)
|
||||
return keys
|
||||
}
|
||||
|
||||
const deleteKeys = async (keys) => {
|
||||
for (const key of [...new Set(keys)]) {
|
||||
await aws(['s3api', 'delete-object', '--bucket', config.bucket, '--key', key])
|
||||
process.stdout.write(`[release] removed old ${config.label} object ${key}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
aws,
|
||||
awsExecutable,
|
||||
listKeys,
|
||||
deleteKeys
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import process from 'node:process'
|
||||
|
||||
export const DOMESTIC_DOWNLOAD_BASE_URL = 'https://download.guanzhangai.cn'
|
||||
export const GLOBAL_DOWNLOAD_BASE_URL = 'https://download.ncurator.com'
|
||||
export const LIVE_PUBLISH_CONFIRMATION = 'publish-to-production'
|
||||
|
||||
const required = (environment, name) => {
|
||||
const value = String(environment[name] || '').trim()
|
||||
if (!value) throw new Error(`missing environment variable: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const normalizedBucket = (value) => value.replace(/^\/+|\/+$/gu, '')
|
||||
const normalizedBaseUrl = (value) => value.replace(/\/$/u, '')
|
||||
|
||||
export const resolveReleaseStorageConfig = (environment = process.env) => {
|
||||
const target = String(environment.RELEASE_STORAGE_TARGET || 'cos')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (target === 'cos') {
|
||||
const region = required(environment, 'COS_REGION')
|
||||
const publicBaseUrl = normalizedBaseUrl(required(environment, 'COS_PUBLIC_BASE_URL'))
|
||||
if (publicBaseUrl !== DOMESTIC_DOWNLOAD_BASE_URL) {
|
||||
throw new Error(`COS_PUBLIC_BASE_URL must be ${DOMESTIC_DOWNLOAD_BASE_URL}`)
|
||||
}
|
||||
return {
|
||||
target,
|
||||
label: 'Tencent COS',
|
||||
bucket: normalizedBucket(required(environment, 'COS_BUCKET')),
|
||||
region,
|
||||
endpoint: `https://cos.${region}.myqcloud.com`,
|
||||
publicBaseUrl,
|
||||
credentials: {
|
||||
AWS_ACCESS_KEY_ID: required(environment, 'TENCENT_SECRET_ID'),
|
||||
AWS_SECRET_ACCESS_KEY: required(environment, 'TENCENT_SECRET_KEY'),
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: 'WHEN_REQUIRED',
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: 'WHEN_REQUIRED'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target === 'r2') {
|
||||
const publicBaseUrl = normalizedBaseUrl(required(environment, 'R2_PUBLIC_BASE_URL'))
|
||||
if (publicBaseUrl !== GLOBAL_DOWNLOAD_BASE_URL) {
|
||||
throw new Error(`R2_PUBLIC_BASE_URL must be ${GLOBAL_DOWNLOAD_BASE_URL}`)
|
||||
}
|
||||
return {
|
||||
target,
|
||||
label: 'Cloudflare R2',
|
||||
bucket: normalizedBucket(required(environment, 'R2_BUCKET_NAME')),
|
||||
region: 'auto',
|
||||
endpoint: `https://${required(environment, 'R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
|
||||
publicBaseUrl,
|
||||
credentials: {
|
||||
AWS_ACCESS_KEY_ID: required(environment, 'R2_ACCESS_KEY_ID'),
|
||||
AWS_SECRET_ACCESS_KEY: required(environment, 'R2_SECRET_ACCESS_KEY')
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('RELEASE_STORAGE_TARGET must be cos or r2')
|
||||
}
|
||||
|
||||
export const assertLivePublishAllowed = (environment = process.env) => {
|
||||
if (String(environment.NCURATOR_ALLOW_LIVE_PUBLISH || '').trim() !== LIVE_PUBLISH_CONFIRMATION) {
|
||||
throw new Error(
|
||||
`live publish is locked; set NCURATOR_ALLOW_LIVE_PUBLISH=${LIVE_PUBLISH_CONFIRMATION} only for an approved production release`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import process from 'node:process'
|
||||
import { DOMESTIC_DOWNLOAD_BASE_URL } from './config.mjs'
|
||||
|
||||
const plan = {
|
||||
mode: 'dry-run',
|
||||
storage: 'Tencent COS',
|
||||
bucket: String(process.env.COS_BUCKET || 'ncurator-1324873382'),
|
||||
region: String(process.env.COS_REGION || 'ap-guangzhou'),
|
||||
publicBaseUrl: String(process.env.COS_PUBLIC_BASE_URL || DOMESTIC_DOWNLOAD_BASE_URL),
|
||||
writes: [
|
||||
'artifacts/<platform>/<tag>/*',
|
||||
'release-status/<tag>/<platform>.json',
|
||||
'updates/latest/latest*.yml',
|
||||
'releases/latest/<platform>.json',
|
||||
'downloads/latest/*'
|
||||
],
|
||||
networkRequests: 0,
|
||||
note: 'This command never uploads, deletes, tags, commits, or pushes.'
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`)
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
inspectLocalWindowsRelease,
|
||||
readPublicReleaseFragment,
|
||||
verifyPublicReleaseFragment,
|
||||
verifyR2ReleaseFragment
|
||||
verifyReleaseStorageFragment
|
||||
} from './shared/windows.mjs'
|
||||
import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './storage/config.mjs'
|
||||
import { prepareReleaseWorkspace } from './workspace.mjs'
|
||||
import { parseReleaseTag } from './tag.mjs'
|
||||
import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs'
|
||||
@@ -75,6 +76,7 @@ const run = (command, commandArgs, options = {}) =>
|
||||
})
|
||||
|
||||
const requirePublish = mode === 'publish'
|
||||
if (requirePublish) assertLivePublishAllowed()
|
||||
const inspection = await inspectLocalWindowsRelease(ROOT, { requirePublish })
|
||||
if (!inspection.ready) {
|
||||
throw new Error(`local Windows release is unavailable:\n- ${inspection.issues.join('\n- ')}`)
|
||||
@@ -151,7 +153,13 @@ const testEnvironment = (environment) => {
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_BUCKET_NAME',
|
||||
'R2_PUBLIC_BASE_URL'
|
||||
'R2_PUBLIC_BASE_URL',
|
||||
'TENCENT_SECRET_ID',
|
||||
'TENCENT_SECRET_KEY',
|
||||
'COS_BUCKET',
|
||||
'COS_REGION',
|
||||
'COS_PUBLIC_BASE_URL',
|
||||
'NCURATOR_ALLOW_LIVE_PUBLISH'
|
||||
]) {
|
||||
delete sanitized[name]
|
||||
}
|
||||
@@ -278,14 +286,15 @@ if (mode === 'test') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '')
|
||||
const storage = resolveReleaseStorageConfig()
|
||||
const publicBaseUrl = storage.publicBaseUrl
|
||||
const existing = await readPublicReleaseFragment(releaseTag, 'windows-x64', publicBaseUrl)
|
||||
if (existing) {
|
||||
await verifyPublicReleaseFragment(existing, publicBaseUrl)
|
||||
await verifyR2ReleaseFragment(existing, inspection.awsExecutable)
|
||||
await verifyReleaseStorageFragment(existing, inspection.awsExecutable)
|
||||
await cleanupLocalTestLogsSafely()
|
||||
await cleanupLocalCandidates()
|
||||
process.stdout.write(`[release] Windows ${releaseTag} is already complete on R2\n`)
|
||||
process.stdout.write(`[release] Windows ${releaseTag} is already complete on ${storage.label}\n`)
|
||||
process.exit(0)
|
||||
}
|
||||
const candidate = await assertCandidatePublishable({
|
||||
@@ -308,7 +317,7 @@ await run(
|
||||
{ cwd: ROOT, env: process.env }
|
||||
)
|
||||
await verifyPublicReleaseFragment(candidate.verified.fragment, publicBaseUrl)
|
||||
await verifyR2ReleaseFragment(candidate.verified.fragment, inspection.awsExecutable)
|
||||
await verifyReleaseStorageFragment(candidate.verified.fragment, inspection.awsExecutable)
|
||||
await markCandidatePublished({ tag: releaseTag, platform: 'windows-x64' })
|
||||
await cleanupLocalTestLogsSafely()
|
||||
await cleanupLocalCandidates()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { OFFICIAL_WEBSITE_URL } from '../../../shared/constants'
|
||||
|
||||
export type TelemetryEventName = 'app_started' | 'message_submit_completed'
|
||||
|
||||
@@ -49,7 +50,7 @@ type PersistedTelemetryState = {
|
||||
|
||||
type DeliveryResult = 'accepted' | 'discard' | 'retry'
|
||||
|
||||
const DEFAULT_ENDPOINT = 'https://www.ncurator.com/api/telemetry/events'
|
||||
const DEFAULT_ENDPOINT = `${OFFICIAL_WEBSITE_URL}/api/telemetry/events`
|
||||
const MAX_PENDING_EVENTS = 500
|
||||
const RETRY_BACKOFF_MS = 30_000
|
||||
const DELIVERY_TIMEOUT_MS = 20_000
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export const APP_ID = 'com.yoan.ncurator'
|
||||
export const USER_MANUAL_URL = 'https://www.ncurator.com/zh/manual'
|
||||
export const FREE_AI_KEYS_URL = 'https://www.ncurator.com/zh/free-keys'
|
||||
export const OFFICIAL_DOWNLOAD_PAGE_URL = 'https://www.ncurator.com/zh'
|
||||
export const OFFICIAL_WEBSITE_URL = 'https://www.guanzhangai.cn'
|
||||
export const USER_MANUAL_URL = `${OFFICIAL_WEBSITE_URL}/zh/manual`
|
||||
export const FREE_AI_KEYS_URL = `${OFFICIAL_WEBSITE_URL}/zh/free-keys`
|
||||
export const OFFICIAL_DOWNLOAD_PAGE_URL = `${OFFICIAL_WEBSITE_URL}/zh`
|
||||
export const NO_EFFECTIVE_FILE_CHANGE_MESSAGE = '本次未检测到文件内容变化'
|
||||
|
||||
export const APP_UPDATE_GET_SNAPSHOT_CHANNEL = 'app-update-get-snapshot'
|
||||
|
||||
@@ -2,6 +2,12 @@ import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import {
|
||||
FREE_AI_KEYS_URL,
|
||||
OFFICIAL_DOWNLOAD_PAGE_URL,
|
||||
OFFICIAL_WEBSITE_URL,
|
||||
USER_MANUAL_URL
|
||||
} from '../../../../src/shared/constants'
|
||||
|
||||
type BuilderConfig = {
|
||||
productName?: string
|
||||
@@ -16,6 +22,21 @@ const readBuilderConfig = async (): Promise<BuilderConfig> =>
|
||||
parse(await readFile(path.resolve('electron-builder.yml'), 'utf8')) as BuilderConfig
|
||||
|
||||
describe('app update publish config', () => {
|
||||
it('uses one domestic website base for every desktop website entry', async () => {
|
||||
const [packageJsonText, telemetryService] = await Promise.all([
|
||||
readFile(path.resolve('package.json'), 'utf8'),
|
||||
readFile(path.resolve('src/main/services/telemetry/TelemetryService.ts'), 'utf8')
|
||||
])
|
||||
const packageJson = JSON.parse(packageJsonText) as { homepage?: string }
|
||||
|
||||
expect(OFFICIAL_WEBSITE_URL).toBe('https://www.guanzhangai.cn')
|
||||
expect(USER_MANUAL_URL).toBe(`${OFFICIAL_WEBSITE_URL}/zh/manual`)
|
||||
expect(FREE_AI_KEYS_URL).toBe(`${OFFICIAL_WEBSITE_URL}/zh/free-keys`)
|
||||
expect(OFFICIAL_DOWNLOAD_PAGE_URL).toBe(`${OFFICIAL_WEBSITE_URL}/zh`)
|
||||
expect(packageJson.homepage).toBe(OFFICIAL_WEBSITE_URL)
|
||||
expect(telemetryService).toContain('`${OFFICIAL_WEBSITE_URL}/api/telemetry/events`')
|
||||
})
|
||||
|
||||
it('uses the direct update feed and updater-aware NSIS behavior', async () => {
|
||||
const [config, include] = await Promise.all([
|
||||
readBuilderConfig(),
|
||||
@@ -24,7 +45,7 @@ describe('app update publish config', () => {
|
||||
|
||||
expect(config.publish).toEqual({
|
||||
provider: 'generic',
|
||||
url: 'https://download.ncurator.com/updates/latest'
|
||||
url: 'https://download.guanzhangai.cn/updates/latest'
|
||||
})
|
||||
expect(config.nsis?.include).toBe('build/nsis-updated-silent.nsh')
|
||||
expect(config.nsis?.oneClick).toBe(false)
|
||||
@@ -87,14 +108,15 @@ describe('app update publish config', () => {
|
||||
expect(prepareRelease).not.toContain('--actions')
|
||||
})
|
||||
|
||||
it('finalizes Windows and macOS release channels independently', async () => {
|
||||
const [windowsRelease, macRelease, windowsFinalize, macFinalize, tagContract] =
|
||||
it('finalizes Windows and macOS release channels independently with a live-publish lock', async () => {
|
||||
const [windowsRelease, macRelease, windowsFinalize, macFinalize, tagContract, storageConfig] =
|
||||
await Promise.all([
|
||||
readFile(path.resolve('scripts/release/windows.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/macos.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/r2/finalize-windows.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/r2/finalize-macos.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/tag.mjs'), 'utf8')
|
||||
readFile(path.resolve('scripts/release/tag.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/storage/config.mjs'), 'utf8')
|
||||
])
|
||||
|
||||
expect(windowsRelease).toContain("'r2', 'finalize-windows.mjs'")
|
||||
@@ -106,6 +128,10 @@ describe('app update publish config', () => {
|
||||
expect(windowsFinalize).not.toContain('--keep-old')
|
||||
expect(macFinalize).not.toContain('--keep-old')
|
||||
expect(tagContract).toContain('/^(mac|windows)-v')
|
||||
expect(windowsRelease).toContain('assertLivePublishAllowed')
|
||||
expect(macRelease).toContain('assertLivePublishAllowed')
|
||||
expect(storageConfig).toContain("RELEASE_STORAGE_TARGET || 'cos'")
|
||||
expect(storageConfig).toContain('download.guanzhangai.cn')
|
||||
})
|
||||
|
||||
it('shares source caches directly and warms packaged E2E before parallel workers', async () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertLivePublishAllowed,
|
||||
resolveReleaseStorageConfig
|
||||
} from '../../../../scripts/release/storage/config.mjs'
|
||||
|
||||
describe('release storage config', () => {
|
||||
it('uses the domestic COS channel by default', () => {
|
||||
const config = resolveReleaseStorageConfig({
|
||||
COS_BUCKET: 'ncurator-1324873382',
|
||||
COS_REGION: 'ap-guangzhou',
|
||||
COS_PUBLIC_BASE_URL: 'https://download.guanzhangai.cn',
|
||||
TENCENT_SECRET_ID: 'test-id',
|
||||
TENCENT_SECRET_KEY: 'test-key'
|
||||
})
|
||||
|
||||
expect(config.target).toBe('cos')
|
||||
expect(config.bucket).toBe('ncurator-1324873382')
|
||||
expect(config.endpoint).toBe('https://cos.ap-guangzhou.myqcloud.com')
|
||||
expect(config.publicBaseUrl).toBe('https://download.guanzhangai.cn')
|
||||
})
|
||||
|
||||
it('retains R2 as an explicit overseas target', () => {
|
||||
const config = resolveReleaseStorageConfig({
|
||||
RELEASE_STORAGE_TARGET: 'r2',
|
||||
R2_ACCESS_KEY_ID: 'test-id',
|
||||
R2_SECRET_ACCESS_KEY: 'test-key',
|
||||
R2_ACCOUNT_ID: 'account',
|
||||
R2_BUCKET_NAME: 'ncurator',
|
||||
R2_PUBLIC_BASE_URL: 'https://download.ncurator.com'
|
||||
})
|
||||
|
||||
expect(config.target).toBe('r2')
|
||||
expect(config.endpoint).toBe('https://account.r2.cloudflarestorage.com')
|
||||
})
|
||||
|
||||
it('blocks every real publish unless the explicit confirmation is present', () => {
|
||||
expect(() => assertLivePublishAllowed({})).toThrow('live publish is locked')
|
||||
expect(() =>
|
||||
assertLivePublishAllowed({ NCURATOR_ALLOW_LIVE_PUBLISH: 'publish-to-production' })
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user