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:
hy committed 2026-09-01 11:44:56 +08:00
1 parent a9960a7dbe
commit d622985d55
26 files changed
+434 -230

No files matched your search

+13 -3
View File
@@ -2,7 +2,7 @@
#! ice #! ice
# AI_LIVE_BASE_URL=https://ice.v.ua # 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 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_TRACING=false
LANGSMITH_ENDPOINT=https://api.smith.langchain.com LANGSMITH_ENDPOINT=https://api.smith.langchain.com
LANGSMITH_PROJECT=ncurator LANGSMITH_PROJECT=ncurator
LANGSMITH_API_KEY=REMOVED_HISTORICAL_KEY LANGSMITH_API_KEY=
LANGCHAIN_CALLBACKS_BACKGROUND=true 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_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY= R2_SECRET_ACCESS_KEY=
R2_ACCOUNT_ID= R2_ACCOUNT_ID=
+1 -1
View File
@@ -1,3 +1,3 @@
provider: generic provider: generic
url: https://download.ncurator.com/updates/latest url: https://download.guanzhangai.cn/updates/latest
updaterCacheDirName: ncurator-updater updaterCacheDirName: ncurator-updater
+2 -2
View File
@@ -102,7 +102,7 @@
#### SET-MODEL-004 免费 AI Key 入口 #### 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-AC1` 入口位于配置数量与“新增配置”之间,并与操作区保持对齐。
- `SET-MODEL-004-AC2` 点击入口后调用系统外链能力打开官网免费 Key 页面,不改变当前设置页路由。 - `SET-MODEL-004-AC2` 点击入口后调用系统外链能力打开官网免费 Key 页面,不改变当前设置页路由。
@@ -286,7 +286,7 @@
#### SET-HELP-001 设置页使用帮助入口 #### SET-HELP-001 设置页使用帮助入口
- 系统行为:设置页左侧栏在“向量模型”下方展示同级、同样式的“使用帮助”入口,点击后通过系统默认浏览器打开官方中文使用手册 `https://www.ncurator.com/zh/manual`,当前设置页保持不变。 - 系统行为:设置页左侧栏在“向量模型”下方展示同级、同样式的“使用帮助”入口,点击后通过系统默认浏览器打开国内官网中文使用手册 `https://www.guanzhangai.cn/zh/manual`,当前设置页保持不变。
- 验收: - 验收:
- `SET-HELP-001-AC1` “使用帮助”入口紧接在“向量模型”下方,使用与其他设置菜单项一致的样式,并具有“查看使用帮助”的可访问名称。 - `SET-HELP-001-AC1` “使用帮助”入口紧接在“向量模型”下方,使用与其他设置菜单项一致的样式,并具有“查看使用帮助”的可访问名称。
- `SET-HELP-001-AC2` 点击“使用帮助”后调用系统外链能力打开官方中文使用手册,不改变当前设置页路由。 - `SET-HELP-001-AC2` 点击“使用帮助”后调用系统外链能力打开官方中文使用手册,不改变当前设置页路由。
+1 -1
View File
@@ -295,7 +295,7 @@
#### WB-COMPOSER-011 使用帮助入口 #### WB-COMPOSER-011 使用帮助入口
- 系统行为:Workbench 左侧栏底部在设置入口旁展示使用帮助按钮,点击后通过系统默认浏览器打开官方中文使用手册 `https://www.ncurator.com/zh/manual`。 - 系统行为:Workbench 左侧栏底部在设置入口旁展示使用帮助按钮,点击后通过系统默认浏览器打开国内官网中文使用手册 `https://www.guanzhangai.cn/zh/manual`。
- 验收: - 验收:
- `WB-COMPOSER-011-AC1` 使用帮助按钮可见,具有“查看使用帮助”的可访问名称和说明。 - `WB-COMPOSER-011-AC1` 使用帮助按钮可见,具有“查看使用帮助”的可访问名称和说明。
- `WB-COMPOSER-011-AC2` 点击使用帮助按钮后,系统打开官方中文使用手册,不改变当前会话和输入草稿。 - `WB-COMPOSER-011-AC2` 点击使用帮助按钮后,系统打开官方中文使用手册,不改变当前会话和输入草稿。
+50 -19
View File
@@ -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> 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. 构建与平台 ## 2. 构建与平台
### Windows ### Windows
@@ -132,9 +140,9 @@ Windows 本地发布环境:
- Windows x64、Node.js 22 x64、项目指定版本的 pnpm; - Windows x64、Node.js 22 x64、项目指定版本的 pnpm;
- Python 3.12 x64、Git、tar; - Python 3.12 x64、Git、tar;
- 发布到 R2 时还需要 AWS CLI 和 R2 配置。 - 发布到 COS 或 R2 时还需要 AWS CLI 和对应存储配置。
`pnpm release:doctor` 当前检查 Windows 本地发布与 R2 访问条件。 `pnpm release:doctor` 检查 Windows 本地发布与当前存储目标的访问条件。
### macOS ### macOS
@@ -184,7 +192,7 @@ release-stage/
## 3. 发布与维护 ## 3. 发布与维护
### R2 与公开通道 ### COS、R2 与公开通道
Windows 和 macOS 使用独立目录、latest metadata 和固定下载地址: Windows 和 macOS 使用独立目录、latest metadata 和固定下载地址:
@@ -203,9 +211,20 @@ downloads/latest/macos-arm64.dmg
downloads/latest/macos-x64.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 ```text
R2_ACCESS_KEY_ID R2_ACCESS_KEY_ID
@@ -215,9 +234,20 @@ R2_BUCKET_NAME
R2_PUBLIC_BASE_URL=https://download.ncurator.com 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 阶段清理。 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 ```text
Windows 清单:https://download.ncurator.com/releases/latest/windows.json Windows 清单:https://download.guanzhangai.cn/releases/latest/windows.json
macOS 清单:https://download.ncurator.com/releases/latest/macos.json macOS 清单:https://download.guanzhangai.cn/releases/latest/macos.json
Windows 下载:https://download.ncurator.com/downloads/latest/windows-x64.exe Windows 下载:https://download.guanzhangai.cn/downloads/latest/windows-x64.exe
macOS arm64:https://download.ncurator.com/downloads/latest/macos-arm64.dmg macOS arm64:https://download.guanzhangai.cn/downloads/latest/macos-arm64.dmg
macOS x64:https://download.ncurator.com/downloads/latest/macos-x64.dmg macOS x64:https://download.guanzhangai.cn/downloads/latest/macos-x64.dmg
Windows 更新:https://download.ncurator.com/updates/latest/latest.yml Windows 更新:https://download.guanzhangai.cn/updates/latest/latest.yml
macOS 更新:https://download.ncurator.com/updates/latest/latest-mac.yml macOS 更新:https://download.guanzhangai.cn/updates/latest/latest-mac.yml
``` ```
应用启动 5 秒后静默检查更新,持续运行时每 4 小时检查一次。发现新版本后由用户主动下载;下载完成后可以立即重启或稍后安装。 应用启动 5 秒后静默检查更新,持续运行时每 4 小时检查一次。发现新版本后由用户主动下载;下载完成后可以立即重启或稍后安装。
@@ -253,10 +283,10 @@ Windows 更新采用“先完成应用关闭、再启动安装器”的时序:
- Tag 已创建但 prepare 中断:基于原 Tag 重新执行 `--prepare-only`。 - Tag 已创建但 prepare 中断:基于原 Tag 重新执行 `--prepare-only`。
- 候选包已生成但测试中断:基于原候选包重新执行 `--test-only`。 - 候选包已生成但测试中断:基于原候选包重新执行 `--test-only`。
- 上传或 finalize 中断:基于原候选包恢复 publish,或使用 `release:r2:finalize`。 - 上传或 finalize 中断:基于原候选包恢复 publish,或使用 `release:storage:finalize`。
- 已发布版本存在问题:通常发布更高版本修复包。回退下载通道不会让已安装客户端自动降级。 - 已发布版本存在问题:通常发布更高版本修复包。回退下载通道不会让已安装客户端自动降级。
同一个 Tag 的 R2 对象如果已存在但哈希不同,禁止覆盖。恢复过程中也不能重新构建并替换候选包。 同一个 Tag 的对象如果已存在但哈希不同,禁止覆盖。恢复过程中也不能重新构建并替换候选包。
## 4. 命令参考 ## 4. 命令参考
@@ -297,7 +327,8 @@ pnpm test:e2e:package -- --bundle <release-bundle.json> --case <caseId-1> --case
```bash ```bash
pnpm release:status -- --tag <windows-vX.Y.Z|mac-vX.Y.Z> 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 通道。 常见失败处理:构建或审计失败时先确认真实运行依赖,不直接放宽包规则;macOS 签名或启动失败时停止该架构发布;正式包 E2E 失败时读取 package summary 和对应 case raw logs;公开 metadata、下载或哈希验证失败时停止更新 latest 通道。
+1 -1
View File
@@ -53,7 +53,7 @@ Settings 顶层 suite 对齐 PRD 模块:
- What:大模型配置页展示免费 AI Key 官网入口,并保持与配置操作区的正确位置关系。 - What:大模型配置页展示免费 AI Key 官网入口,并保持与配置操作区的正确位置关系。
- Why:用户需要在新增配置前快速获得可体验的 Key,同时外链操作不能破坏当前设置页状态。 - Why:用户需要在新增配置前快速获得可体验的 Key,同时外链操作不能破坏当前设置页状态。
- How:进入大模型配置页,检查入口位于配置数量与“新增配置”之间,点击后收集真实 Renderer → Preload 外链调用信号。 - 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 的实时可用性。 - Not:不访问外部网页内容,也不验证免费 Key 的实时可用性。
#### `model-delete-config-feedback` #### `model-delete-config-feedback`
+1 -1
View File
@@ -74,4 +74,4 @@ dmg:
npmRebuild: false npmRebuild: false
publish: publish:
provider: generic provider: generic
url: https://download.ncurator.com/updates/latest url: https://download.guanzhangai.cn/updates/latest
+3 -1
View File
@@ -4,7 +4,7 @@
"description": "Local-first AI workspace for documents, research, and task execution", "description": "Local-first AI workspace for documents, research, and task execution",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "NCurator", "author": "NCurator",
"homepage": "https://www.ncurator.com", "homepage": "https://www.guanzhangai.cn",
"scripts": { "scripts": {
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint --cache .", "lint": "eslint --cache .",
@@ -34,8 +34,10 @@
"release:win": "node scripts/release/windows.mjs", "release:win": "node scripts/release/windows.mjs",
"release:doctor": "node scripts/release/doctor.mjs", "release:doctor": "node scripts/release/doctor.mjs",
"release:status": "node scripts/release/status.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:signing:setup": "node scripts/release/setup-macos-signing.mjs",
"release:mac": "node scripts/release/macos.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", "release:r2:finalize": "node scripts/release/r2/finalize.mjs",
"test:workbench": "node scripts/test/workflow.mjs --domain workbench --type e2e --target source", "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", "test:workbench:e2e": "node scripts/test/workflow.mjs --domain workbench --type e2e --target source",
+22
View File
@@ -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/*"]
}
]
}
+17 -6
View File
@@ -3,6 +3,7 @@ import process from 'node:process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { loadReleaseEnvironment } from './shared/environment.mjs' import { loadReleaseEnvironment } from './shared/environment.mjs'
import { inspectLocalWindowsRelease } from './shared/windows.mjs' import { inspectLocalWindowsRelease } from './shared/windows.mjs'
import { resolveReleaseStorageConfig } from './storage/config.mjs'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const ROOT = process.cwd() const ROOT = process.cwd()
@@ -22,14 +23,24 @@ if (!inspection.ready) {
process.stdout.write('- pnpm configured version: ready\n') process.stdout.write('- pnpm configured version: ready\n')
process.stdout.write('- AWS CLI: ready\n') process.stdout.write('- AWS CLI: ready\n')
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` const storage = resolveReleaseStorageConfig()
const bucket = String(process.env.R2_BUCKET_NAME).replace(/^\/+|\/+$/gu, '')
await execFileAsync( await execFileAsync(
inspection.awsExecutable, 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) => { ).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
View File
@@ -18,6 +18,7 @@ import { createReleaseStageDeadline } from './shared/stage-deadline.mjs'
import { prepareReleaseWorkspace } from './workspace.mjs' import { prepareReleaseWorkspace } from './workspace.mjs'
import { parseReleaseTag } from './tag.mjs' import { parseReleaseTag } from './tag.mjs'
import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs' import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs'
import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './storage/config.mjs'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const ROOT = process.cwd() const ROOT = process.cwd()
@@ -56,6 +57,7 @@ if (!releaseVersion || modes.length !== 1) {
) )
} }
const mode = modes[0][0] const mode = modes[0][0]
if (mode === 'publish') assertLivePublishAllowed()
if (includeX64E2E && mode !== 'test') { if (includeX64E2E && mode !== 'test') {
throw new Error('--include-x64-e2e is only valid with --test-only') throw new Error('--include-x64-e2e is only valid with --test-only')
} }
@@ -113,22 +115,7 @@ const validateEnvironment = async () => {
} }
if (mode === 'publish') { if (mode === 'publish') {
for (const name of [ resolveReleaseStorageConfig()
'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')
}
const awsExecutable = const awsExecutable =
String(process.env.NCURATOR_AWS_CLI || '').trim() || String(process.env.NCURATOR_AWS_CLI || '').trim() ||
path.join(defaultToolRoot, 'awscli', 'bin', 'aws') path.join(defaultToolRoot, 'awscli', 'bin', 'aws')
@@ -227,7 +214,13 @@ const sanitizedTestEnvironment = () => {
'AWS_SECRET_ACCESS_KEY', 'AWS_SECRET_ACCESS_KEY',
'R2_ACCOUNT_ID', 'R2_ACCOUNT_ID',
'R2_BUCKET_NAME', '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] delete environment[name]
} }
+3 -71
View File
@@ -1,71 +1,3 @@
import { execFile } from 'node:child_process' // Compatibility entry point. Shared release metadata remains under r2/, while the
import { existsSync } from 'node:fs' // storage client now supports Tencent COS (default) and Cloudflare R2.
import os from 'node:os' export { createReleaseStorageClient as createR2Client } from '../storage/client.mjs'
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 }
}
+4 -3
View File
@@ -6,7 +6,7 @@ import process from 'node:process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { assertReleaseFragment } from './fragment-contract.mjs' import { assertReleaseFragment } from './fragment-contract.mjs'
import { fetchWithRetry } from '../shared/http.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 { artifactKey, fixedDownloadKey, statusKey } from './storage-layout.mjs'
import { parseReleaseTag } from '../tag.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>') 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 workDirectory = await mkdtemp(path.join(os.tmpdir(), `ncurator-macos-${releaseTag}-`))
const downloadAndVerifyFragment = async (platform) => { const downloadAndVerifyFragment = async (platform) => {
@@ -43,7 +44,7 @@ const downloadAndVerifyFragment = async (platform) => {
.stdout .stdout
) )
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) { 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 return fragment
+4 -3
View File
@@ -4,7 +4,7 @@ import path from 'node:path'
import process from 'node:process' import process from 'node:process'
import { assertReleaseFragment } from './fragment-contract.mjs' import { assertReleaseFragment } from './fragment-contract.mjs'
import { artifactKey, fixedDownloadKey, statusKey } from './storage-layout.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 { fetchWithRetry } from '../shared/http.mjs'
import { parseReleaseTag } from '../tag.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>') 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}-`)) const workDirectory = await mkdtemp(path.join(os.tmpdir(), `ncurator-windows-${releaseTag}-`))
try { try {
@@ -39,7 +40,7 @@ try {
.stdout .stdout
) )
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) { 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}`)
} }
} }
+5 -33
View File
@@ -1,31 +1,13 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import process from 'node:process' import process from 'node:process'
import { promisify } from 'node:util'
import { loadReleaseEnvironment } from '../shared/environment.mjs'
import { assertReleaseFragment } from './fragment-contract.mjs' import { assertReleaseFragment } from './fragment-contract.mjs'
import { fetchWithRetry } from '../shared/http.mjs' import { fetchWithRetry } from '../shared/http.mjs'
import { artifactKey, statusKey } from './storage-layout.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 [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) { if (!inputDirectory || !fragmentPath) {
throw new Error('usage: node scripts/release/r2/upload-fragment.mjs <input-dir> <fragment-json>') 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')) const fragment = JSON.parse(await readFile(fragmentPath, 'utf8'))
assertReleaseFragment(fragment) assertReleaseFragment(fragment)
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` const { aws, bucket, publicBaseUrl, label } = await createReleaseStorageClient()
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 contentType = (name) => { const contentType = (name) => {
if (name.endsWith('.json')) return 'application/json' if (name.endsWith('.json')) return 'application/json'
@@ -99,9 +71,9 @@ for (const file of fragment.files) {
} }
const remote = await readRemoteObject(key) 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) { 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}`, { const publicResponse = await fetchWithRetry(`${publicBaseUrl}/${key}`, {
@@ -109,7 +81,7 @@ for (const file of fragment.files) {
cache: 'no-store' cache: 'no-store'
}) })
if (!publicResponse.ok || Number(publicResponse.headers.get('content-length')) !== file.size) { 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}`)
} }
} }
-7
View File
@@ -25,13 +25,6 @@ export const loadReleaseEnvironment = async (root) => {
if (!process.env[name]) process.env[name] = value 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) { if (process.platform === 'win32' && !process.env.NCURATOR_AWS_CLI) {
const candidates = [ const candidates = [
process.env.ProgramFiles process.env.ProgramFiles
+36 -41
View File
@@ -8,10 +8,11 @@ import { loadReleaseEnvironment } from './environment.mjs'
import { assertReleaseFragment } from '../r2/fragment-contract.mjs' import { assertReleaseFragment } from '../r2/fragment-contract.mjs'
import { fetchWithRetry } from './http.mjs' import { fetchWithRetry } from './http.mjs'
import { artifactKey, statusKey } from '../r2/storage-layout.mjs' import { artifactKey, statusKey } from '../r2/storage-layout.mjs'
import { DOMESTIC_DOWNLOAD_BASE_URL, resolveReleaseStorageConfig } from '../storage/config.mjs'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const WINDOWS_PLATFORM = 'windows-x64' 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 = {}) => { const commandResult = async (command, args, options = {}) => {
try { try {
@@ -139,20 +140,10 @@ export const inspectLocalWindowsRelease = async (root, { requirePublish = true }
} }
if (requirePublish) { if (requirePublish) {
for (const name of [ try {
'AWS_ACCESS_KEY_ID', resolveReleaseStorageConfig()
'AWS_SECRET_ACCESS_KEY', } catch (error) {
'R2_ACCOUNT_ID', issues.push(error instanceof Error ? error.message : String(error))
'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}`)
} }
} }
@@ -192,42 +183,46 @@ export const verifyPublicReleaseFragment = async (
publicBaseUrl = PUBLIC_RELEASE_BASE_URL publicBaseUrl = PUBLIC_RELEASE_BASE_URL
) => { ) => {
for (const file of fragment.files) { for (const file of fragment.files) {
const response = await fetchWithRetry(`${publicBaseUrl}/${artifactKey(fragment, file.storageName)}`, { const response = await fetchWithRetry(
method: 'HEAD', `${publicBaseUrl}/${artifactKey(fragment, file.storageName)}`,
cache: 'no-store' {
}) method: 'HEAD',
cache: 'no-store'
}
)
if (!response.ok || Number(response.headers.get('content-length')) !== file.size) { if (!response.ok || Number(response.headers.get('content-length')) !== file.size) {
throw new Error(`remote release asset verification failed: ${file.name}`) throw new Error(`remote release asset verification failed: ${file.name}`)
} }
} }
} }
export const verifyR2ReleaseFragment = async (fragment, awsExecutable) => { export const verifyReleaseStorageFragment = async (fragment, awsExecutable) => {
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` const config = resolveReleaseStorageConfig()
const bucket = String(process.env.R2_BUCKET_NAME || '').replace(/^\/+|\/+$/gu, '') if (!awsExecutable) throw new Error(`${config.label} verification configuration is incomplete`)
if (!awsExecutable || !bucket || !process.env.R2_ACCOUNT_ID) {
throw new Error('R2 verification configuration is incomplete')
}
for (const file of fragment.files) { for (const file of fragment.files) {
const key = artifactKey(fragment, file.storageName) const key = artifactKey(fragment, file.storageName)
const result = await commandResult(awsExecutable, [ const result = await commandResult(
's3api', awsExecutable,
'head-object', [
'--bucket', 's3api',
bucket, 'head-object',
'--key', '--bucket',
key, config.bucket,
'--output', '--key',
'json', key,
'--endpoint-url', '--output',
endpoint, 'json',
'--region', '--endpoint-url',
'auto' config.endpoint,
]) '--region',
if (!result.ok) throw new Error(`R2 release asset lookup failed: ${key}`) 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) const remote = JSON.parse(result.stdout)
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) { 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}`)
} }
} }
} }
+9 -6
View File
@@ -6,9 +6,10 @@ import {
readPublicReleaseFragment, readPublicReleaseFragment,
resolveAwsExecutable, resolveAwsExecutable,
verifyPublicReleaseFragment, verifyPublicReleaseFragment,
verifyR2ReleaseFragment verifyReleaseStorageFragment
} from './shared/windows.mjs' } from './shared/windows.mjs'
import { parseReleaseTag } from './tag.mjs' import { parseReleaseTag } from './tag.mjs'
import { resolveReleaseStorageConfig } from './storage/config.mjs'
const ROOT = process.cwd() const ROOT = process.cwd()
await loadReleaseEnvironment(ROOT) 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>') 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( let publicBaseUrl = PUBLIC_RELEASE_BASE_URL
/\/$/u, 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 platforms = releaseFamily === 'mac' ? ['macos-arm64', 'macos-x64'] : ['windows-x64']
const awsExecutable = await resolveAwsExecutable() const awsExecutable = await resolveAwsExecutable()
if (!awsExecutable) throw new Error('AWS CLI is required to verify release sha512 metadata') if (!awsExecutable) throw new Error('AWS CLI is required to verify release sha512 metadata')
@@ -36,7 +39,7 @@ for (const platform of platforms) {
continue continue
} }
await verifyPublicReleaseFragment(fragment, publicBaseUrl) await verifyPublicReleaseFragment(fragment, publicBaseUrl)
await verifyR2ReleaseFragment(fragment, awsExecutable) await verifyReleaseStorageFragment(fragment, awsExecutable)
const application = fragment.audit.applications[0] const application = fragment.audit.applications[0]
const installedMiB = (Number(application.totalBytes || 0) / 1024 / 1024).toFixed(1) const installedMiB = (Number(application.totalBytes || 0) / 1024 / 1024).toFixed(1)
process.stdout.write(`${platform.padEnd(14)} ready, audited, installed ${installedMiB} MiB\n`) process.stdout.write(`${platform.padEnd(14)} ready, audited, installed ${installedMiB} MiB\n`)
+69
View File
@@ -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
}
}
+68
View File
@@ -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`
)
}
}
+21
View File
@@ -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`)
+15 -6
View File
@@ -16,8 +16,9 @@ import {
inspectLocalWindowsRelease, inspectLocalWindowsRelease,
readPublicReleaseFragment, readPublicReleaseFragment,
verifyPublicReleaseFragment, verifyPublicReleaseFragment,
verifyR2ReleaseFragment verifyReleaseStorageFragment
} from './shared/windows.mjs' } from './shared/windows.mjs'
import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './storage/config.mjs'
import { prepareReleaseWorkspace } from './workspace.mjs' import { prepareReleaseWorkspace } from './workspace.mjs'
import { parseReleaseTag } from './tag.mjs' import { parseReleaseTag } from './tag.mjs'
import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs' import { hasRegressionSelectionArgs, readRegressionSelection } from './regression.mjs'
@@ -75,6 +76,7 @@ const run = (command, commandArgs, options = {}) =>
}) })
const requirePublish = mode === 'publish' const requirePublish = mode === 'publish'
if (requirePublish) assertLivePublishAllowed()
const inspection = await inspectLocalWindowsRelease(ROOT, { requirePublish }) const inspection = await inspectLocalWindowsRelease(ROOT, { requirePublish })
if (!inspection.ready) { if (!inspection.ready) {
throw new Error(`local Windows release is unavailable:\n- ${inspection.issues.join('\n- ')}`) throw new Error(`local Windows release is unavailable:\n- ${inspection.issues.join('\n- ')}`)
@@ -151,7 +153,13 @@ const testEnvironment = (environment) => {
'AWS_SECRET_ACCESS_KEY', 'AWS_SECRET_ACCESS_KEY',
'R2_ACCOUNT_ID', 'R2_ACCOUNT_ID',
'R2_BUCKET_NAME', '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] delete sanitized[name]
} }
@@ -278,14 +286,15 @@ if (mode === 'test') {
process.exit(0) 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) const existing = await readPublicReleaseFragment(releaseTag, 'windows-x64', publicBaseUrl)
if (existing) { if (existing) {
await verifyPublicReleaseFragment(existing, publicBaseUrl) await verifyPublicReleaseFragment(existing, publicBaseUrl)
await verifyR2ReleaseFragment(existing, inspection.awsExecutable) await verifyReleaseStorageFragment(existing, inspection.awsExecutable)
await cleanupLocalTestLogsSafely() await cleanupLocalTestLogsSafely()
await cleanupLocalCandidates() 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) process.exit(0)
} }
const candidate = await assertCandidatePublishable({ const candidate = await assertCandidatePublishable({
@@ -308,7 +317,7 @@ await run(
{ cwd: ROOT, env: process.env } { cwd: ROOT, env: process.env }
) )
await verifyPublicReleaseFragment(candidate.verified.fragment, publicBaseUrl) 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 markCandidatePublished({ tag: releaseTag, platform: 'windows-x64' })
await cleanupLocalTestLogsSafely() await cleanupLocalTestLogsSafely()
await cleanupLocalCandidates() await cleanupLocalCandidates()
@@ -1,6 +1,7 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { OFFICIAL_WEBSITE_URL } from '../../../shared/constants'
export type TelemetryEventName = 'app_started' | 'message_submit_completed' export type TelemetryEventName = 'app_started' | 'message_submit_completed'
@@ -49,7 +50,7 @@ type PersistedTelemetryState = {
type DeliveryResult = 'accepted' | 'discard' | 'retry' 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 MAX_PENDING_EVENTS = 500
const RETRY_BACKOFF_MS = 30_000 const RETRY_BACKOFF_MS = 30_000
const DELIVERY_TIMEOUT_MS = 20_000 const DELIVERY_TIMEOUT_MS = 20_000
+4 -3
View File
@@ -1,7 +1,8 @@
export const APP_ID = 'com.yoan.ncurator' export const APP_ID = 'com.yoan.ncurator'
export const USER_MANUAL_URL = 'https://www.ncurator.com/zh/manual' export const OFFICIAL_WEBSITE_URL = 'https://www.guanzhangai.cn'
export const FREE_AI_KEYS_URL = 'https://www.ncurator.com/zh/free-keys' export const USER_MANUAL_URL = `${OFFICIAL_WEBSITE_URL}/zh/manual`
export const OFFICIAL_DOWNLOAD_PAGE_URL = 'https://www.ncurator.com/zh' 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 NO_EFFECTIVE_FILE_CHANGE_MESSAGE = '本次未检测到文件内容变化'
export const APP_UPDATE_GET_SNAPSHOT_CHANNEL = 'app-update-get-snapshot' 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 path from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parse } from 'yaml' 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 = { type BuilderConfig = {
productName?: string productName?: string
@@ -16,6 +22,21 @@ const readBuilderConfig = async (): Promise<BuilderConfig> =>
parse(await readFile(path.resolve('electron-builder.yml'), 'utf8')) as BuilderConfig parse(await readFile(path.resolve('electron-builder.yml'), 'utf8')) as BuilderConfig
describe('app update publish config', () => { 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 () => { it('uses the direct update feed and updater-aware NSIS behavior', async () => {
const [config, include] = await Promise.all([ const [config, include] = await Promise.all([
readBuilderConfig(), readBuilderConfig(),
@@ -24,7 +45,7 @@ describe('app update publish config', () => {
expect(config.publish).toEqual({ expect(config.publish).toEqual({
provider: 'generic', 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?.include).toBe('build/nsis-updated-silent.nsh')
expect(config.nsis?.oneClick).toBe(false) expect(config.nsis?.oneClick).toBe(false)
@@ -87,14 +108,15 @@ describe('app update publish config', () => {
expect(prepareRelease).not.toContain('--actions') expect(prepareRelease).not.toContain('--actions')
}) })
it('finalizes Windows and macOS release channels independently', async () => { it('finalizes Windows and macOS release channels independently with a live-publish lock', async () => {
const [windowsRelease, macRelease, windowsFinalize, macFinalize, tagContract] = const [windowsRelease, macRelease, windowsFinalize, macFinalize, tagContract, storageConfig] =
await Promise.all([ await Promise.all([
readFile(path.resolve('scripts/release/windows.mjs'), 'utf8'), readFile(path.resolve('scripts/release/windows.mjs'), 'utf8'),
readFile(path.resolve('scripts/release/macos.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-windows.mjs'), 'utf8'),
readFile(path.resolve('scripts/release/r2/finalize-macos.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'") expect(windowsRelease).toContain("'r2', 'finalize-windows.mjs'")
@@ -106,6 +128,10 @@ describe('app update publish config', () => {
expect(windowsFinalize).not.toContain('--keep-old') expect(windowsFinalize).not.toContain('--keep-old')
expect(macFinalize).not.toContain('--keep-old') expect(macFinalize).not.toContain('--keep-old')
expect(tagContract).toContain('/^(mac|windows)-v') 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 () => { 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()
})
})