feat: integrate GitHub releases and prepare public repository
Signed-off-by: hy <xiaoyuan9816@gmail.com>
This commit is contained in:
1 parent
79d1b30a6c
commit
04295587e1
16 files changed
+764
-175
No files matched your search
@@ -2,6 +2,7 @@ node_modules
|
||||
dist
|
||||
out
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.tsbuildinfo
|
||||
|
||||
+8
-11
@@ -6,8 +6,6 @@
|
||||
|
||||
<p align="center"><strong>Building the most user-friendly AI assistant for local office documents</strong></p>
|
||||
|
||||
<p align="center">Read your files. Edit your documents. Review the changes. Get work done.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">简体中文</a> · <strong>English</strong>
|
||||
</p>
|
||||
@@ -49,18 +47,17 @@ See the [website](https://www.guanzhangai.cn/zh) for demos and real task example
|
||||
2. Choose an available model or configure your own endpoint and API key.
|
||||
3. Create a partner, add a working folder, and give it a task. To use the knowledge base, download an embedding model and build an index first.
|
||||
|
||||
The app is free; model costs depend on the service you choose. Files and indexes are stored locally. Calls to external models or network services send relevant content as needed for the task.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Join the discussion and help shape these plans:
|
||||
|
||||
| Direction | Planned work |
|
||||
| ---------------------------- | -------------------------------------------------------------------------- |
|
||||
| 🌍 Chinese and English | Chinese and English UI, plus evaluation of English system and tool prompts |
|
||||
| 🧩 Skill integration | Connect and manage custom and third-party Skills |
|
||||
| 🧭 Browser data import | Import data from your browser, starting with bookmarks and history |
|
||||
| 🖼️ Multimodal knowledge base | Parse, index, and retrieve images and audio with source references |
|
||||
| Direction | Planned work |
|
||||
| ---------------------------- | ------------------------------------------------------------------ |
|
||||
| 🌍 English support | An English user interface |
|
||||
| 🔤 English knowledge base | English embedding models and indexing |
|
||||
| 🧩 Skill integration | Connect and manage custom and third-party Skills |
|
||||
| 🧭 Browser data import | Import data from your browser, starting with bookmarks and history |
|
||||
| 🖼️ Multimodal knowledge base | Parse, index, and retrieve images and audio with source references |
|
||||
|
||||
## Get involved
|
||||
|
||||
@@ -83,7 +80,7 @@ See the [contribution guide](./CONTRIBUTING.en.md#development-and-validation) fo
|
||||
|
||||
## Browser extension
|
||||
|
||||
A separately maintained bookmark viewer and URL copying tool: [Chrome](https://chromewebstore.google.com/detail/ncurator-knowledge-base-a/jdlfflbeekclkgcckcfombmfojmcdeio?hl=zh-CN) · [Microsoft Edge](https://microsoftedge.microsoft.com/addons/detail/hkdfdbpkmkpooopbkdghecbaipeoijpj).
|
||||
A separately maintained bookmark viewer and URL copying tool: [Source repository](https://github.com/Yoan98/Ncurator-Extension) · [Chrome](https://chromewebstore.google.com/detail/ncurator-knowledge-base-a/jdlfflbeekclkgcckcfombmfojmcdeio?hl=zh-CN) · [Microsoft Edge](https://microsoftedge.microsoft.com/addons/detail/hkdfdbpkmkpooopbkdghecbaipeoijpj).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ pnpm dev
|
||||
|
||||
## 浏览器插件
|
||||
|
||||
独立维护的收藏夹查看与链接复制工具:[Chrome](https://chromewebstore.google.com/detail/ncurator-knowledge-base-a/jdlfflbeekclkgcckcfombmfojmcdeio?hl=zh-CN) · [Microsoft Edge](https://microsoftedge.microsoft.com/addons/detail/hkdfdbpkmkpooopbkdghecbaipeoijpj)。
|
||||
独立维护的收藏夹查看与链接复制工具:[源码仓库](https://github.com/Yoan98/Ncurator-Extension) · [Chrome](https://chromewebstore.google.com/detail/ncurator-knowledge-base-a/jdlfflbeekclkgcckcfombmfojmcdeio?hl=zh-CN) · [Microsoft Edge](https://microsoftedge.microsoft.com/addons/detail/hkdfdbpkmkpooopbkdghecbaipeoijpj)。
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
### Windows
|
||||
|
||||
本地 Windows x64:Node.js 22 x64、项目指定 pnpm、Python 3.12 x64、Git、tar;发 COS 时还需 AWS CLI 与 COS 存储配置。仅未来海外版才需要 R2 配置。`pnpm release:doctor` 检查本机条件。prepare / test / publish 只消费本机候选包。
|
||||
本地 Windows x64:Node.js 22 x64、项目指定 pnpm、Python 3.12 x64、Git、tar;发布时还需 AWS CLI、COS 存储配置,以及已登录且具备公开仓库写权限的 GitHub CLI。仅未来海外版才需要 R2 配置。`pnpm release:doctor` 检查本机条件。prepare / test / publish 只消费本机候选包。
|
||||
|
||||
### macOS
|
||||
|
||||
prepare 必须在 Apple Silicon Mac;需已信任的 `Ncurator Update Signing` P12(自建更新签名,`timestamp: none`、`notarize: false`,非 Apple Developer ID 公证)。候选包须完成正式包 E2E。不构建、测试或发布 Intel Mac。
|
||||
prepare 必须在 Apple Silicon Mac;需已信任的 `Ncurator Update Signing` P12(自建更新签名,`timestamp: none`、`notarize: false`,非 Apple Developer ID 公证)。候选包须完成正式包 E2E;发布时需 AWS CLI、COS 存储配置及已登录的 GitHub CLI。不构建、测试或发布 Intel Mac。
|
||||
|
||||
### 构建与审计硬约束
|
||||
|
||||
@@ -43,15 +43,15 @@ CDN 节点缓存遵循源站 `Cache-Control`:安装包为 `public, max-age=315
|
||||
|
||||
### GitHub Releases 手动下载
|
||||
|
||||
公开仓库 `Yoan98/Ncurator` 的 Releases 作为安装包手动下载镜像,上传与 COS 上线版本相同、已完成正式包回归的文件,不重新构建。Windows 与 macOS 沿用各自独立 Tag,每条 Release 包含对应平台的 EXE 或 DMG、`SHA256SUMS` 和版本说明;自动更新文件继续由 COS 通道提供。
|
||||
公开仓库 `Yoan98/Ncurator` 的 Releases 作为安装包下载镜像,由现有平台发布命令通过 GitHub CLI 自动上传与 COS 上线版本相同、已完成正式包回归的文件,不重新构建。GitHub 登录凭证由 CLI 管理,不新增项目密钥配置。Windows 与 macOS 沿用各自独立 Tag,每条 Release 包含对应平台的 EXE 或 DMG、`SHA256SUMS` 和版本说明;自动更新文件继续由 COS 通道提供。
|
||||
|
||||
Release Tag 必须对应实际构建源码。首次公开或补发旧版本时,先核对公开源码快照与原构建代码的对应关系,不把含凭证的私有历史整体推入公开仓库。GitHub 自动附带的 Source code 压缩包不替代安装包,也不替代核对完整对应源码的义务。
|
||||
Release 的公开 annotated Tag 必须与候选 bundle 的构建 commit 完全一致;公开源码后从该源码创建新的 Tag 和候选包。自动流程不代建 Tag,不把含凭证的私有历史推入公开仓库,也不处理历史包与公开快照的 commit 映射。GitHub 自动附带的 Source code 压缩包不替代安装包,也不替代核对完整对应源码的义务。
|
||||
|
||||
Windows 和 macOS 独立发布,GitHub 的 Latest 标记不能代表两个平台都已更新。README 使用仓库 Releases 列表作为 GitHub 下载入口,不依赖跨平台共用的 `releases/latest/download` 地址。具体上传与恢复操作见[发布流程](./release.md#31-github-releases-镜像)。
|
||||
|
||||
仅当有意发布未来海外版时才切 R2(非当前常规循环):`RELEASE_STORAGE_TARGET=r2`,并设置 `R2_ACCESS_KEY_ID`、`R2_SECRET_ACCESS_KEY`、`R2_ACCOUNT_ID`、`R2_BUCKET_NAME`、`R2_PUBLIC_BASE_URL=https://download.ncurator.com`。
|
||||
|
||||
publish 上传候选与 fragment,更新该平台自动更新 metadata 与官网 manifest,验证公开 yml / 清单 / artifacts 的大小、sha512 与 manifest URL(安装包 URL 须以用户可见文件名结尾)。验证成功后标记 `published`,清理本地 `tests/logs/**`,并清理该平台更早 artifacts(含旧 `release-status/`)。失败、prepare-only、test-only 不清理 logs。
|
||||
publish 先校验 GitHub 源码身份并准备草稿及附件,再上传 COS 候选与 fragment,更新该平台自动更新 metadata 与官网 manifest,验证公开 yml / 清单 / artifacts 的大小、sha512 与 manifest URL(安装包 URL 须以用户可见文件名结尾);随后公开 GitHub Release,并通过匿名下载核对大小与 SHA-256。两边成功后才标记 `published`,清理本地 `tests/logs/**`,并清理该平台更早 artifacts(含旧 `release-status/`)。失败、prepare-only、test-only 不清理 logs。跨服务发布不具备原子性;中断保留同一候选包,重跑 publish 时检查实际官网与更新通道状态、补齐 GitHub 草稿附件并继续,不重建、不覆盖已有附件,也不把较新通道降级。
|
||||
|
||||
### 本地保留与清理
|
||||
|
||||
|
||||
+27
-25
@@ -1,6 +1,6 @@
|
||||
# 桌面端发布流程
|
||||
|
||||
官网和应用内自动更新正式发布使用腾讯云 COS,一次发布完成后不再把同一 Tag 上传到 Cloudflare R2。GitHub Releases 可额外提供同一正式安装包的手动下载镜像,按 3.1 节单独上传;现有 `release:win` / `release:mac` 不会自动创建 GitHub Release。R2 仅供未来海外版显式使用 `RELEASE_STORAGE_TARGET=r2`。当前发布平台为 Windows x64 和 macOS arm64,平台 Tag 分别使用 `windows-vX.Y.Z` 与 `mac-vX.Y.Z`,版本号独立递增。
|
||||
官网和应用内自动更新正式发布使用腾讯云 COS,一次发布完成后不再把同一 Tag 上传到 Cloudflare R2。GitHub Releases 同时提供同一正式安装包的下载镜像;`release:win` / `release:mac` 的 `--publish-only` 自动完成两边发布,详见 3.1 节。R2 仅供未来海外版显式使用 `RELEASE_STORAGE_TARGET=r2`。当前发布平台为 Windows x64 和 macOS arm64,平台 Tag 分别使用 `windows-vX.Y.Z` 与 `mac-vX.Y.Z`,版本号独立递增。
|
||||
|
||||
## 1. 发布流程
|
||||
|
||||
@@ -12,14 +12,16 @@
|
||||
4. 对比当前 Tag 与上一已发布同平台版本,评估改动影响并确定正式包回归范围。
|
||||
5. 在实际安装的候选包上完成确定范围内的正式包 E2E。
|
||||
6. 记录构建信息、回归结果和剩余风险。
|
||||
7. 发布测试通过的同一份候选包到 COS,验证公开下载和更新通道。
|
||||
8. 清理本地临时工作区和过期候选包。
|
||||
7. 校验 GitHub 登录、公开仓库权限及 Tag 对应的构建 commit,创建或恢复草稿,上传同一安装包和校验文件。
|
||||
8. 将同一候选包发布到 COS,验证官网下载和自动更新通道。
|
||||
9. 公开 GitHub Release,匿名下载安装包并校验大小与 SHA-256。
|
||||
10. 两边全部验证成功后,标记发布完成并清理本地临时工作区和过期候选包。
|
||||
|
||||
候选包测试或公开验证失败时,不得放宽构建、审计、哈希和公开通道校验。
|
||||
|
||||
### 1.1 发布准备与 Tag
|
||||
|
||||
必须在干净且与 `origin/main` 完全一致的 `main` 上开始。新版本必须高于该平台当前仍存在的最高 Tag。发版机须具备 live `.env`,包括 `AI_LIVE_KEY_*`;默认 provider 为 `dashscope`。
|
||||
必须在干净且与 `origin/main` 完全一致的 `main` 上开始。公开发布前应已完成桌面端仓库迁移,`origin` 指向 `Yoan98/Ncurator`,后续 Tag 与构建均基于公开源码。新版本必须高于该平台当前仍存在的最高 Tag。发版机须具备 live `.env`,包括 `AI_LIVE_KEY_*`;默认 provider 为 `dashscope`。
|
||||
|
||||
`release:start` 会依次完成:
|
||||
|
||||
@@ -50,7 +52,7 @@ pnpm release:abort --tag windows-vX.Y.Z
|
||||
# 或 mac-vX.Y.Z
|
||||
```
|
||||
|
||||
`release:abort` 拒绝删除已 live 或本地状态为 `published` 的 Tag,同时删除远端和本地 annotated Tag 及该 Tag 的本地候选目录。`main` 上已经产生的 prepare 提交会保留。已上线版本有问题时必须发布更高版本,不能 abort live Tag。
|
||||
`release:abort` 拒绝删除已 live 或本地状态为 `published` 的 Tag,同时删除该 Tag 的 GitHub 草稿、远端和本地 annotated Tag 及本地候选目录;若 GitHub Release 已公开则拒绝中止。`main` 上已经产生的 prepare 提交会保留。已上线版本有问题时必须发布更高版本,不能 abort live Tag。
|
||||
|
||||
## 2. 候选包与正式包回归
|
||||
|
||||
@@ -65,7 +67,7 @@ prepared → ready → published
|
||||
└→ rejected
|
||||
```
|
||||
|
||||
正式包 E2E 通过后候选包变为 `ready`;测试失败变为 `rejected`;公开发布并验证成功后变为 `published`。候选包、bundle 和对象存储布局见 [`design.md`](./design.md)。
|
||||
正式包 E2E 通过后候选包变为 `ready`;测试失败变为 `rejected`;COS 和 GitHub 均公开发布并验证成功后变为 `published`。候选包、bundle 和对象存储布局见 [`design.md`](./design.md)。
|
||||
|
||||
### 2.2 影响评估
|
||||
|
||||
@@ -100,7 +102,7 @@ git diff <previous-platform-tag>..<current-platform-tag>
|
||||
|
||||
## 3. 发布与公开验证
|
||||
|
||||
publish 只读取已经测试通过的候选包,不重新构建。默认发布到 COS,验证国内公开下载、更新 metadata、公开 manifest、文件大小、sha512 和 URL 成功后才算完成。对象键、CDN Cache-Control 和公开 URL 见 [`design.md`](./design.md)。
|
||||
publish 只读取已经测试通过的候选包,不重新构建。先准备 GitHub 草稿与附件,再发布 COS 并验证国内下载、更新 metadata、公开 manifest、文件大小、sha512 和 URL,最后公开并验证 GitHub Release;两边全部通过才算完成。对象键、CDN Cache-Control 和公开 URL 见 [`design.md`](./design.md)。
|
||||
|
||||
真实发布默认锁定。用户已批准本次上线后,在当前终端设置:
|
||||
|
||||
@@ -114,34 +116,34 @@ export NCURATOR_ALLOW_LIVE_PUBLISH=publish-to-production
|
||||
|
||||
### 3.1 GitHub Releases 镜像
|
||||
|
||||
前置条件:桌面端源码已发布至 `Yoan98/Ncurator`,该版本的公开 Tag 与实际构建代码对应;同一安装包已经过正式包回归并在 COS 上线验证。首次公开或补发历史版本时,核对并记录原构建源码与公开快照的对应关系,不直接推送含凭证的私有历史,不把旧安装包挂在无关源码 Tag 下。
|
||||
|
||||
1. 在发版机使用 `verifyReleaseBundle` 复验原候选 bundle、文件大小与哈希,取 `role: installer` 的 EXE 或 DMG,保持原公开文件名和字节内容。
|
||||
2. 准备 `SHA256SUMS`:使用 bundle 中该安装包的 `sha256`,每行格式为 `<sha256> <公开安装包文件名>`。准备版本说明文件,写明平台、版本、更新内容、安装方式及对应源码 Tag;有源码快照映射时说明其对应关系。
|
||||
3. 在 GitHub 的 Releases 页面新建草稿,选择已核对的 Tag,上传安装包和 `SHA256SUMS`。只上传对应平台的安装包,不上传候选目录、日志、`.env` 或签名材料。
|
||||
4. 核对草稿中的平台、Tag、文件名和版本说明后发布。GitHub 自动生成的 Source code 下载项是源码压缩包,普通用户安装应选择 EXE / DMG。
|
||||
5. 从公开 Release 下载安装包,核对大小与 SHA-256 和原 bundle 一致,再确认 README 的 Releases 入口可访问该版本。GitHub 失败不改动已上线的 COS 下载和自动更新通道。
|
||||
|
||||
也可使用已安装并登录、具备目标仓库发布权限的 GitHub CLI 准备草稿。以下命令中的 Tag 和文件路径应替换为核对过的本地文件;`--verify-tag` 防止从默认分支误建 Tag:
|
||||
发版机安装 [GitHub CLI](https://cli.github.com/),并使用具有 `Yoan98/Ncurator` 写权限的账号完成一次浏览器授权。macOS 可用 `brew install gh`;Windows 在实际发版机安装 CLI 并单独登录。
|
||||
|
||||
```bash
|
||||
release_tag="mac-vX.Y.Z"
|
||||
release_installer="/absolute/path/馆长-X.Y.Z-macos-arm64.dmg"
|
||||
gh release create "$release_tag" "$release_installer" "/absolute/path/SHA256SUMS" \
|
||||
--repo Yoan98/Ncurator --verify-tag --draft \
|
||||
--title "馆长 X.Y.Z · macOS arm64" \
|
||||
--notes-file "/absolute/path/release-notes.md"
|
||||
gh auth login --hostname github.com --web --git-protocol ssh --skip-ssh-key
|
||||
gh auth status --hostname github.com
|
||||
```
|
||||
|
||||
Windows 版本使用 `windows-vX.Y.Z`、对应 EXE 及平台标题。上传中断时检查已有草稿,通过 Releases 页面或 `gh release upload` 补齐缺失附件;不使用 `--clobber` 覆盖已公开安装包。公开版本内容有问题时发布更高版本。
|
||||
登录凭证由 CLI 管理,不写入项目 `.env`,无需新增 GitHub Token 配置。每个平台继续使用原有发布命令,GitHub 不另设发布开关或上传步骤。
|
||||
|
||||
操作依据:[GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases)、[gh release create](https://cli.github.com/manual/gh_release_create)、[gh release upload](https://cli.github.com/manual/gh_release_upload)。
|
||||
`--publish-only` 按以下规则执行:
|
||||
|
||||
1. 复验已测试的候选 bundle,并核对公开仓库的 annotated Tag 指向同一构建 commit。Tag 不存在、源码不一致、登录或权限不足时,在写入 COS 前失败;脚本不会代建或推送 Tag。
|
||||
2. 创建 GitHub 草稿,或继续使用同 Tag 的已有草稿。只上传对应平台的 EXE / DMG 和 `SHA256SUMS`;版本说明取 Tag 注释并附源码和官网更新日志入口。保留安装包公开文件名,不上传更新文件、bundle、日志或签名材料。
|
||||
3. 校验已有附件的大小和 SHA-256;缺少服务端 digest 的旧附件下载复验。中断后只补齐缺失附件,可清除草稿内上传失败留下的 `starter` 附件;内容不同则失败,不覆盖已有安装包。
|
||||
4. 发布并验证 COS。若官网下载与更新通道已经指向该候选包,复验后直接继续 GitHub;若只是上传了文件但尚未切换通道,则继续完成 COS 发布。拒绝把公开通道降级到较旧版本。
|
||||
5. 将草稿公开,再通过匿名下载复验附件大小与 SHA-256;已公开且内容相同的 Release 只验证。全部成功后候选包才标记 `published`。
|
||||
|
||||
Windows 与 macOS 保持独立 Tag 和 Release,不设置跨平台共用的 Latest 标记。GitHub 的 Source code 下载项是源码压缩包,用户安装应选择 EXE / DMG。
|
||||
|
||||
首次公开应从清理后的公开源码创建 Tag 和候选包,不推送含凭证、内部资料的私有历史。自动发布要求公开 Tag 与候选 commit 完全一致,不处理历史安装包与公开快照的 commit 映射;补发旧版本需另行核对源码对应关系。
|
||||
|
||||
操作依据:[CLI 登录](https://cli.github.com/manual/gh_auth_login)、[创建 Release](https://cli.github.com/manual/gh_release_create)、[上传附件](https://cli.github.com/manual/gh_release_upload)。
|
||||
|
||||
## 4. 中断与恢复
|
||||
|
||||
- Tag 已创建但 prepare 中断:同 Tag 重跑 `--prepare-only`;如果源码已改变且版本尚未上线,先 abort 再用同版本重打。
|
||||
- 候选包已生成但测试中断:对同一候选包重跑 `--test-only`。
|
||||
- 上传或 finalize 中断:对同一候选包恢复 publish,或执行 `release:storage:finalize`。
|
||||
- 上传、COS finalize、GitHub 公开或下载验证中断:修复原因后,对同一候选包重跑原平台的 `--publish-only`;按已有附件和公开通道继续,不重新构建、不覆盖已公开附件。COS 已上线但 GitHub 失败时保留候选包和测试证据,不能宣称整次发布完成;`release:storage:finalize` 只恢复 COS,不完成 GitHub。
|
||||
- 未上线整轮作废:abort 后用同一版本重打,不要另起版本号。
|
||||
- 已发布版本有问题:发布更高版本;回退下载通道不会让已安装客户端自动降级。
|
||||
- 同一 Tag 的对象已存在但哈希不同:禁止覆盖;未上线时执行 abort 后用同一版本重打。
|
||||
|
||||
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
|
||||
import { resolveCandidateDirectory } from './candidate.mjs'
|
||||
import { findLiveChannelsForTag } from './shared/live-tag.mjs'
|
||||
import { parseReleaseTag } from './tag.mjs'
|
||||
import { createGitHubReleasePublisher } from './github.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const ROOT = process.cwd()
|
||||
@@ -98,6 +99,8 @@ if (!remoteExists && !localExists) {
|
||||
throw new Error(`tag not found locally or on origin: ${releaseTag}`)
|
||||
}
|
||||
|
||||
await createGitHubReleasePublisher({ root: ROOT }).deleteDraft(releaseTag)
|
||||
|
||||
if (remoteExists) {
|
||||
await execFileAsync('git', ['push', 'origin', `:refs/tags/${releaseTag}`], { cwd: ROOT })
|
||||
process.stdout.write(`[release] deleted origin tag ${releaseTag}\n`)
|
||||
|
||||
@@ -152,14 +152,22 @@ export const recordCandidateE2E = async ({
|
||||
return { ...candidate, state }
|
||||
}
|
||||
|
||||
export const assertCandidatePublishable = async ({ tag, platform, commit }) => {
|
||||
export const assertCandidatePublishable = async ({
|
||||
tag,
|
||||
platform,
|
||||
commit,
|
||||
allowPublished = false
|
||||
}) => {
|
||||
const candidate = await readReleaseCandidate({ tag, platform })
|
||||
if (candidate.state.commit !== commit) {
|
||||
throw new Error(
|
||||
`candidate commit ${candidate.state.commit} does not match ${tag} commit ${commit}`
|
||||
)
|
||||
}
|
||||
if (candidate.state.status !== 'ready') {
|
||||
if (
|
||||
candidate.state.status !== 'ready' &&
|
||||
!(allowPublished && candidate.state.status === 'published')
|
||||
) {
|
||||
throw new Error(
|
||||
`candidate ${tag} ${platform} is ${candidate.state.status}; prepare or test it before publishing`
|
||||
)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { resolveReleaseAssetPath } from './r2/fragment-contract.mjs'
|
||||
import { assertLivePublishAllowed } from './storage/config.mjs'
|
||||
import { parseReleaseTag } from './tag.mjs'
|
||||
import { fetchWithRetry } from './shared/http.mjs'
|
||||
|
||||
export const GITHUB_RELEASE_REPOSITORY = 'Yoan98/Ncurator'
|
||||
const REPOSITORY_PATH = `repos/${GITHUB_RELEASE_REPOSITORY}`
|
||||
const REPOSITORY_URL = `https://github.com/${GITHUB_RELEASE_REPOSITORY}`
|
||||
const execFileAsync = promisify(execFile)
|
||||
const sha256 = (content) => createHash('sha256').update(content).digest('hex')
|
||||
|
||||
export const createGitHubReleasePublisher = ({
|
||||
root = process.cwd(),
|
||||
execute = execFileAsync,
|
||||
fetchImpl = fetchWithRetry
|
||||
} = {}) => {
|
||||
const gh = async (args) => {
|
||||
try {
|
||||
return await execute('gh', args, {
|
||||
cwd: root,
|
||||
timeout: 20 * 60_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
'GitHub CLI is missing; install gh and run gh auth login --hostname github.com'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const api = async (endpoint) =>
|
||||
JSON.parse((await gh(['api', '--hostname', 'github.com', '--method', 'GET', endpoint])).stdout)
|
||||
const releaseArgs = (tag) => [tag, '--repo', `github.com/${GITHUB_RELEASE_REPOSITORY}`]
|
||||
|
||||
const readRelease = async (tag) => {
|
||||
try {
|
||||
// CLI resolves draft releases too; REST's /releases/tags endpoint only finds published ones.
|
||||
const { databaseId } = JSON.parse(
|
||||
(await gh(['release', 'view', ...releaseArgs(tag), '--json', 'databaseId'])).stdout
|
||||
)
|
||||
return await api(`${REPOSITORY_PATH}/releases/${databaseId}`)
|
||||
} catch (error) {
|
||||
if (/^release not found\s*$/mu.test(String(error.stderr || error.message))) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const verifySource = async (bundle) => {
|
||||
const repository = await api(REPOSITORY_PATH)
|
||||
if (repository.private || !repository.permissions?.push) {
|
||||
throw new Error(
|
||||
`GitHub publication requires write access to public ${GITHUB_RELEASE_REPOSITORY}`
|
||||
)
|
||||
}
|
||||
const reference = await api(`${REPOSITORY_PATH}/git/ref/tags/${bundle.tag}`)
|
||||
if (reference.object?.type !== 'tag') {
|
||||
throw new Error(`GitHub ${bundle.tag} must be an annotated tag`)
|
||||
}
|
||||
const tag = await api(`${REPOSITORY_PATH}/git/tags/${reference.object.sha}`)
|
||||
if (tag.object?.type !== 'commit' || tag.object.sha !== bundle.commit) {
|
||||
throw new Error(`GitHub ${bundle.tag} does not match candidate commit ${bundle.commit}`)
|
||||
}
|
||||
return tag.message.trim()
|
||||
}
|
||||
|
||||
const verifyAssets = async (release, files) => {
|
||||
if (!release || release.prerelease || release.assets.length !== files.length) {
|
||||
throw new Error('GitHub Release must contain only the installer and SHA256SUMS')
|
||||
}
|
||||
for (const file of files) {
|
||||
const asset = release.assets.find((entry) => entry.name === file.name)
|
||||
if (!asset || asset.state !== 'uploaded' || asset.size !== file.size) {
|
||||
throw new Error(`GitHub Release asset is missing or incomplete: ${file.name}`)
|
||||
}
|
||||
if (asset.digest) {
|
||||
if (asset.digest !== `sha256:${file.sha256}`) {
|
||||
throw new Error(`GitHub Release asset hash differs: ${file.name}`)
|
||||
}
|
||||
} else {
|
||||
// Older GitHub assets may have no digest. Verify their bytes before reusing them.
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-github-verify-'))
|
||||
try {
|
||||
await gh([
|
||||
'release',
|
||||
'download',
|
||||
...releaseArgs(release.tag_name),
|
||||
'--pattern',
|
||||
file.name,
|
||||
'--dir',
|
||||
directory
|
||||
])
|
||||
const content = await readFile(path.join(directory, file.name))
|
||||
if (content.length !== file.size || sha256(content) !== file.sha256) {
|
||||
throw new Error(`GitHub Release asset hash differs: ${file.name}`)
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async prepare(candidate) {
|
||||
assertLivePublishAllowed()
|
||||
if (!['ready', 'published'].includes(candidate.state.status)) {
|
||||
throw new Error('GitHub publication requires a tested release candidate')
|
||||
}
|
||||
const { bundle, directory } = candidate.verified
|
||||
const annotation = await verifySource(bundle)
|
||||
const installer = bundle.files.find((file) => file.role === 'installer')
|
||||
const staging = path.join(candidate.directory, 'github')
|
||||
await mkdir(staging, { recursive: true })
|
||||
const installerPath = await resolveReleaseAssetPath(directory, installer)
|
||||
const uploadPath =
|
||||
path.basename(installerPath) === installer.name
|
||||
? installerPath
|
||||
: path.join(staging, installer.name)
|
||||
if (uploadPath !== installerPath) await copyFile(installerPath, uploadPath)
|
||||
const sums = `${installer.sha256} ${installer.name}\n`
|
||||
const sumsPath = path.join(staging, 'SHA256SUMS')
|
||||
await writeFile(sumsPath, sums, 'utf8')
|
||||
const files = [
|
||||
{ ...installer, path: uploadPath },
|
||||
{ name: 'SHA256SUMS', path: sumsPath, size: Buffer.byteLength(sums), sha256: sha256(sums) }
|
||||
]
|
||||
const { family, version } = parseReleaseTag(bundle.tag)
|
||||
const platform = family === 'windows' ? 'Windows x64' : 'macOS arm64'
|
||||
const notesPath = path.join(staging, 'release-notes.md')
|
||||
await writeFile(
|
||||
notesPath,
|
||||
[
|
||||
annotation,
|
||||
'',
|
||||
`平台:${platform}。下载下方的 ${family === 'windows' ? 'EXE' : 'DMG'} 安装包。`,
|
||||
'安装包与官网下载版本一致;SHA256SUMS 提供 SHA-256 校验值。',
|
||||
'',
|
||||
`[对应源码](${REPOSITORY_URL}/tree/${bundle.tag}) · [官网更新日志](https://www.guanzhangai.cn/zh/changelog)`,
|
||||
''
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
let release = await readRelease(bundle.tag)
|
||||
if (!release) {
|
||||
await gh([
|
||||
'release',
|
||||
'create',
|
||||
...releaseArgs(bundle.tag),
|
||||
'--verify-tag',
|
||||
'--draft',
|
||||
'--latest=false',
|
||||
'--title',
|
||||
`馆长 ${version} · ${platform}`,
|
||||
'--notes-file',
|
||||
notesPath
|
||||
])
|
||||
release = await readRelease(bundle.tag)
|
||||
}
|
||||
if (!release) throw new Error(`GitHub Release draft is missing: ${bundle.tag}`)
|
||||
if (release.draft) {
|
||||
const expectedNames = new Set(files.map((file) => file.name))
|
||||
if (release.prerelease || release.assets.some((asset) => !expectedNames.has(asset.name))) {
|
||||
throw new Error('GitHub draft contains unexpected assets or is marked prerelease')
|
||||
}
|
||||
for (const file of files) {
|
||||
const existing = release.assets.find((asset) => asset.name === file.name)
|
||||
if (existing?.state === 'starter') {
|
||||
await gh(['release', 'delete-asset', ...releaseArgs(bundle.tag), file.name, '--yes'])
|
||||
} else if (existing) {
|
||||
continue
|
||||
}
|
||||
await gh(['release', 'upload', ...releaseArgs(bundle.tag), file.path])
|
||||
}
|
||||
release = await readRelease(bundle.tag)
|
||||
}
|
||||
await verifyAssets(release, files)
|
||||
process.stdout.write(`[release] GitHub assets verified for ${bundle.tag}\n`)
|
||||
return { bundle, files, releaseId: release.id }
|
||||
},
|
||||
|
||||
async publish(prepared) {
|
||||
assertLivePublishAllowed()
|
||||
const { bundle, files, releaseId } = prepared
|
||||
await verifySource(bundle)
|
||||
let release = await readRelease(bundle.tag)
|
||||
if (release?.id !== releaseId) throw new Error('GitHub Release changed during publication')
|
||||
await verifyAssets(release, files)
|
||||
if (release.draft) {
|
||||
await gh(['release', 'edit', ...releaseArgs(bundle.tag), '--draft=false', '--latest=false'])
|
||||
release = await readRelease(bundle.tag)
|
||||
}
|
||||
if (!release || release.draft) throw new Error('GitHub Release is not public')
|
||||
await verifyAssets(release, files)
|
||||
// Anonymous downloads verify that users can fetch the same bytes, independently of CLI auth.
|
||||
for (const file of files) {
|
||||
const asset = release.assets.find((entry) => entry.name === file.name)
|
||||
const response = await fetchImpl(asset.browser_download_url, {
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(20 * 60_000)
|
||||
})
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`GitHub public download failed: ${file.name} (HTTP ${response.status})`)
|
||||
}
|
||||
const hash = createHash('sha256')
|
||||
let size = 0
|
||||
for await (const chunk of response.body) {
|
||||
size += chunk.length
|
||||
hash.update(chunk)
|
||||
}
|
||||
if (size !== file.size || hash.digest('hex') !== file.sha256) {
|
||||
throw new Error(`GitHub public download hash differs: ${file.name}`)
|
||||
}
|
||||
}
|
||||
process.stdout.write(`[release] GitHub ${bundle.tag} is public: ${release.html_url}\n`)
|
||||
},
|
||||
|
||||
async deleteDraft(tag) {
|
||||
const release = await readRelease(tag)
|
||||
if (!release) return
|
||||
if (!release.draft) throw new Error(`GitHub ${tag} is published; refuse to abort`)
|
||||
await gh(['release', 'delete', ...releaseArgs(tag), '--yes'])
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-12
@@ -23,6 +23,12 @@ import {
|
||||
validateReleaseArgs
|
||||
} from './regression.mjs'
|
||||
import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './storage/config.mjs'
|
||||
import { createGitHubReleasePublisher } from './github.mjs'
|
||||
import {
|
||||
isReleaseChannelCurrent,
|
||||
verifyPublicReleaseFragment,
|
||||
verifyReleaseStorageFragment
|
||||
} from './shared/live-tag.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const ROOT = process.cwd()
|
||||
@@ -320,18 +326,37 @@ if (mode === 'test') {
|
||||
const candidate = await assertCandidatePublishable({
|
||||
tag: releaseTag,
|
||||
platform: MACOS_PLATFORM,
|
||||
commit: taggedCommit
|
||||
commit: taggedCommit,
|
||||
allowPublished: true
|
||||
})
|
||||
await run(process.execPath, [
|
||||
path.join(ROOT, 'scripts', 'release', 'r2', 'upload-fragment.mjs'),
|
||||
candidate.verified.directory,
|
||||
candidate.verified.fragmentPath
|
||||
])
|
||||
await run(process.execPath, [
|
||||
path.join(ROOT, 'scripts', 'release', 'r2', 'finalize-macos.mjs'),
|
||||
'--tag',
|
||||
releaseTag
|
||||
])
|
||||
const storage = resolveReleaseStorageConfig()
|
||||
const github = createGitHubReleasePublisher({ root: ROOT })
|
||||
const channelCurrent = await isReleaseChannelCurrent(
|
||||
candidate.verified.fragment,
|
||||
storage.publicBaseUrl
|
||||
)
|
||||
if (!channelCurrent && candidate.state.status === 'published') {
|
||||
throw new Error(`published ${releaseTag} is missing from ${storage.label}; refuse to republish`)
|
||||
}
|
||||
const githubRelease = await github.prepare(candidate)
|
||||
if (!channelCurrent) {
|
||||
await run(process.execPath, [
|
||||
path.join(ROOT, 'scripts', 'release', 'r2', 'upload-fragment.mjs'),
|
||||
candidate.verified.directory,
|
||||
candidate.verified.fragmentPath
|
||||
])
|
||||
await run(process.execPath, [
|
||||
path.join(ROOT, 'scripts', 'release', 'r2', 'finalize-macos.mjs'),
|
||||
'--tag',
|
||||
releaseTag
|
||||
])
|
||||
}
|
||||
await verifyPublicReleaseFragment(candidate.verified.fragment, storage.publicBaseUrl)
|
||||
await verifyReleaseStorageFragment(candidate.verified.fragment, process.env.NCURATOR_AWS_CLI)
|
||||
if (!(await isReleaseChannelCurrent(candidate.verified.fragment, storage.publicBaseUrl))) {
|
||||
throw new Error('public macOS release channel does not match the candidate')
|
||||
}
|
||||
await github.publish(githubRelease)
|
||||
await markCandidatePublished({ tag: releaseTag, platform: MACOS_PLATFORM })
|
||||
try {
|
||||
await cleanupLocalTestLogs(ROOT)
|
||||
@@ -351,4 +376,4 @@ try {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
process.stderr.write(`[release] local candidate cleanup warning: ${message}\n`)
|
||||
}
|
||||
process.stdout.write(`[release] macOS ${releaseTag} published from retained candidates\n`)
|
||||
process.stdout.write(`[release] macOS ${releaseTag} published to ${storage.label} and GitHub\n`)
|
||||
@@ -1,16 +1,81 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
DOMESTIC_DOWNLOAD_BASE_URL,
|
||||
GLOBAL_DOWNLOAD_BASE_URL
|
||||
GLOBAL_DOWNLOAD_BASE_URL,
|
||||
resolveReleaseStorageConfig
|
||||
} from '../storage/config.mjs'
|
||||
import { fetchWithRetry } from './http.mjs'
|
||||
import { parseReleaseTag } from '../tag.mjs'
|
||||
import { compareReleaseVersions, parseReleaseTag } from '../tag.mjs'
|
||||
import { artifactKey } from '../r2/storage-layout.mjs'
|
||||
import YAML from 'yaml'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const LATEST_MANIFEST = {
|
||||
mac: 'releases/latest/macos.json',
|
||||
windows: 'releases/latest/windows.json'
|
||||
}
|
||||
|
||||
export const RELEASE_PUBLIC_BASE_URLS = [DOMESTIC_DOWNLOAD_BASE_URL, GLOBAL_DOWNLOAD_BASE_URL]
|
||||
export const RELEASE_PUBLIC_BASE_URLS = [
|
||||
DOMESTIC_DOWNLOAD_BASE_URL,
|
||||
GLOBAL_DOWNLOAD_BASE_URL,
|
||||
resolveReleaseStorageConfig
|
||||
]
|
||||
|
||||
export const isReleaseChannelCurrent = async (fragment, base, fetchImpl = fetchWithRetry) => {
|
||||
const { family, version } = parseReleaseTag(fragment.tag, fragment.platform)
|
||||
const metadataPath =
|
||||
family === 'mac' ? 'updates/latest/latest-mac.yml' : 'updates/latest/latest.yml'
|
||||
const responses = await Promise.all(
|
||||
[LATEST_MANIFEST[family], metadataPath].map((key) =>
|
||||
fetchImpl(`${base}/${key}?check=${Date.now()}`, { cache: 'no-store' })
|
||||
)
|
||||
)
|
||||
for (const response of responses) {
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`public release channel lookup failed: HTTP ${response.status}`)
|
||||
}
|
||||
}
|
||||
const manifest = responses[0].ok ? await responses[0].json() : undefined
|
||||
const metadata = responses[1].ok ? YAML.parse(await responses[1].text()) : undefined
|
||||
for (const current of [manifest, metadata]) {
|
||||
if (!current) continue
|
||||
if (!/^\d+\.\d+\.\d+$/u.test(current.version)) {
|
||||
throw new Error('public release channel version is invalid')
|
||||
}
|
||||
if (compareReleaseVersions(current.version, version) > 0) {
|
||||
throw new Error(
|
||||
`public channel already contains newer ${current.version}; refuse to downgrade`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (manifest?.version === version && manifest.tag !== fragment.tag) {
|
||||
throw new Error(`public channel tag differs from ${fragment.tag}`)
|
||||
}
|
||||
const installer = fragment.files.find((file) => file.role === 'installer')
|
||||
const download = manifest?.downloads?.[family === 'mac' ? 'arm64' : 'x64']
|
||||
const expectedUpdates = fragment.files.filter((file) =>
|
||||
family === 'mac' ? file.name.endsWith('.zip') : file.role === 'installer'
|
||||
)
|
||||
return (
|
||||
manifest?.tag === fragment.tag &&
|
||||
metadata?.version === version &&
|
||||
download?.name === installer.name &&
|
||||
download.size === installer.size &&
|
||||
download.sha512 === installer.sha512 &&
|
||||
download.url === `${base}/${artifactKey(fragment, installer.name)}` &&
|
||||
expectedUpdates.every((file) =>
|
||||
metadata.files?.some(
|
||||
(entry) =>
|
||||
decodeURI(new URL(entry.url, `${base}/${metadataPath}`).href) ===
|
||||
`${base}/${artifactKey(fragment, file.name)}` &&
|
||||
entry.sha512 === file.sha512 &&
|
||||
entry.size === file.size
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const findLiveChannelsForTag = async (releaseTag, fetchImpl = fetchWithRetry) => {
|
||||
const { family } = parseReleaseTag(releaseTag)
|
||||
@@ -28,3 +93,47 @@ export const findLiveChannelsForTag = async (releaseTag, fetchImpl = fetchWithRe
|
||||
|
||||
return live
|
||||
}
|
||||
|
||||
export const verifyPublicReleaseFragment = async (
|
||||
fragment,
|
||||
publicBaseUrl = DOMESTIC_DOWNLOAD_BASE_URL
|
||||
) => {
|
||||
for (const file of fragment.files) {
|
||||
const response = await fetchWithRetry(`${publicBaseUrl}/${artifactKey(fragment, file.name)}`, {
|
||||
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 verifyReleaseStorageFragment = async (fragment, awsExecutable) => {
|
||||
const config = resolveReleaseStorageConfig()
|
||||
if (!awsExecutable) throw new Error(`${config.label} verification configuration is incomplete`)
|
||||
const { createAwsCliRuntime } = await import('../storage/client.mjs')
|
||||
const runtime = await createAwsCliRuntime(config)
|
||||
for (const file of fragment.files) {
|
||||
const key = artifactKey(fragment, file.name)
|
||||
const result = await execFileAsync(
|
||||
awsExecutable,
|
||||
[
|
||||
's3api',
|
||||
'head-object',
|
||||
'--bucket',
|
||||
config.bucket,
|
||||
'--key',
|
||||
key,
|
||||
'--output',
|
||||
'json',
|
||||
...runtime.extraArgs
|
||||
],
|
||||
{ env: runtime.env }
|
||||
)
|
||||
const remote = JSON.parse(result.stdout)
|
||||
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
|
||||
throw new Error(`${config.label} release asset does not match fragment: ${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,9 @@ import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { loadReleaseEnvironment } from './environment.mjs'
|
||||
import { assertReleaseFragment } from '../r2/fragment-contract.mjs'
|
||||
import { fetchWithRetry } from './http.mjs'
|
||||
import { artifactKey, fragmentKey } from '../r2/storage-layout.mjs'
|
||||
import { DOMESTIC_DOWNLOAD_BASE_URL, resolveReleaseStorageConfig } from '../storage/config.mjs'
|
||||
import { resolveReleaseStorageConfig } from '../storage/config.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const WINDOWS_PLATFORM = 'windows-x64'
|
||||
const PUBLIC_RELEASE_BASE_URL = DOMESTIC_DOWNLOAD_BASE_URL
|
||||
|
||||
const commandResult = async (command, args, options = {}) => {
|
||||
try {
|
||||
@@ -131,6 +126,11 @@ export const inspectLocalWindowsRelease = async (root, { requirePublish = true }
|
||||
|
||||
const awsExecutable = requirePublish ? await resolveAwsExecutable() : ''
|
||||
if (requirePublish && !awsExecutable) issues.push('AWS CLI is not available')
|
||||
if (requirePublish && !(await commandResult('gh', ['--version'])).ok) {
|
||||
issues.push(
|
||||
'GitHub CLI is not available; install gh and run gh auth login --hostname github.com'
|
||||
)
|
||||
}
|
||||
|
||||
for (const [command, label] of [
|
||||
['git', 'Git'],
|
||||
@@ -155,75 +155,3 @@ export const inspectLocalWindowsRelease = async (root, { requirePublish = true }
|
||||
python
|
||||
}
|
||||
}
|
||||
|
||||
export const readPublicReleaseFragment = async (
|
||||
releaseTag,
|
||||
platform,
|
||||
publicBaseUrl = PUBLIC_RELEASE_BASE_URL
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`${publicBaseUrl}/${fragmentKey(releaseTag, platform)}?check=${Date.now()}`,
|
||||
{ cache: 'no-store' }
|
||||
)
|
||||
if (response.status === 404) return undefined
|
||||
if (!response.ok) {
|
||||
throw new Error(`release status lookup failed for ${platform}: HTTP ${response.status}`)
|
||||
}
|
||||
const fragment = await response.json()
|
||||
assertReleaseFragment(fragment, {
|
||||
tag: releaseTag,
|
||||
platform,
|
||||
label: `remote release fragment ${platform}`
|
||||
})
|
||||
return fragment
|
||||
}
|
||||
|
||||
export const verifyPublicReleaseFragment = async (
|
||||
fragment,
|
||||
publicBaseUrl = PUBLIC_RELEASE_BASE_URL
|
||||
) => {
|
||||
for (const file of fragment.files) {
|
||||
const response = await fetchWithRetry(
|
||||
`${publicBaseUrl}/${artifactKey(fragment, file.name)}`,
|
||||
{
|
||||
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 verifyReleaseStorageFragment = async (fragment, awsExecutable) => {
|
||||
const config = resolveReleaseStorageConfig()
|
||||
if (!awsExecutable) throw new Error(`${config.label} verification configuration is incomplete`)
|
||||
const { createAwsCliRuntime } = await import('../storage/client.mjs')
|
||||
const runtime = await createAwsCliRuntime(config)
|
||||
for (const file of fragment.files) {
|
||||
const key = artifactKey(fragment, file.name)
|
||||
const result = await commandResult(
|
||||
awsExecutable,
|
||||
[
|
||||
's3api',
|
||||
'head-object',
|
||||
'--bucket',
|
||||
config.bucket,
|
||||
'--key',
|
||||
key,
|
||||
'--output',
|
||||
'json',
|
||||
...runtime.extraArgs
|
||||
],
|
||||
{ env: runtime.env }
|
||||
)
|
||||
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(`${config.label} release asset does not match fragment: ${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { PUBLIC_RELEASE_BASE_URL, WINDOWS_PLATFORM }
|
||||
+36
-31
@@ -12,12 +12,7 @@ import {
|
||||
readReleaseCandidate,
|
||||
recordCandidateE2E
|
||||
} from './candidate.mjs'
|
||||
import {
|
||||
inspectLocalWindowsRelease,
|
||||
readPublicReleaseFragment,
|
||||
verifyPublicReleaseFragment,
|
||||
verifyReleaseStorageFragment
|
||||
} from './shared/windows.mjs'
|
||||
import { inspectLocalWindowsRelease } from './shared/windows.mjs'
|
||||
import { assertLivePublishAllowed, resolveReleaseStorageConfig } from './storage/config.mjs'
|
||||
import { prepareReleaseWorkspace } from './workspace.mjs'
|
||||
import { parseReleaseTag } from './tag.mjs'
|
||||
@@ -29,6 +24,12 @@ import {
|
||||
import { cleanupLocalTestLogs } from './shared/test-logs.mjs'
|
||||
import { createReleaseStageDeadline } from './shared/stage-deadline.mjs'
|
||||
import { loadReleaseEnvironment } from './shared/environment.mjs'
|
||||
import { createGitHubReleasePublisher } from './github.mjs'
|
||||
import {
|
||||
isReleaseChannelCurrent,
|
||||
verifyPublicReleaseFragment,
|
||||
verifyReleaseStorageFragment
|
||||
} from './shared/live-tag.mjs'
|
||||
import { cpSync, existsSync } from 'node:fs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -307,37 +308,41 @@ if (mode === 'test') {
|
||||
|
||||
const storage = resolveReleaseStorageConfig()
|
||||
const publicBaseUrl = storage.publicBaseUrl
|
||||
const existing = await readPublicReleaseFragment(releaseTag, 'windows-x64', publicBaseUrl)
|
||||
if (existing) {
|
||||
await verifyPublicReleaseFragment(existing, publicBaseUrl)
|
||||
await verifyReleaseStorageFragment(existing, inspection.awsExecutable)
|
||||
await cleanupLocalTestLogsSafely()
|
||||
await cleanupLocalCandidates()
|
||||
process.stdout.write(`[release] Windows ${releaseTag} is already complete on ${storage.label}\n`)
|
||||
process.exit(0)
|
||||
}
|
||||
const candidate = await assertCandidatePublishable({
|
||||
tag: releaseTag,
|
||||
platform: 'windows-x64',
|
||||
commit: taggedCommit
|
||||
commit: taggedCommit,
|
||||
allowPublished: true
|
||||
})
|
||||
await run(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(ROOT, 'scripts', 'release', 'r2', 'upload-fragment.mjs'),
|
||||
candidate.verified.directory,
|
||||
candidate.verified.fragmentPath
|
||||
],
|
||||
{ cwd: ROOT, env: process.env }
|
||||
)
|
||||
await run(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'scripts', 'release', 'r2', 'finalize-windows.mjs'), '--tag', releaseTag],
|
||||
{ cwd: ROOT, env: process.env }
|
||||
)
|
||||
const github = createGitHubReleasePublisher({ root: ROOT })
|
||||
const channelCurrent = await isReleaseChannelCurrent(candidate.verified.fragment, publicBaseUrl)
|
||||
if (!channelCurrent && candidate.state.status === 'published') {
|
||||
throw new Error(`published ${releaseTag} is missing from ${storage.label}; refuse to republish`)
|
||||
}
|
||||
const githubRelease = await github.prepare(candidate)
|
||||
if (!channelCurrent) {
|
||||
await run(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(ROOT, 'scripts', 'release', 'r2', 'upload-fragment.mjs'),
|
||||
candidate.verified.directory,
|
||||
candidate.verified.fragmentPath
|
||||
],
|
||||
{ cwd: ROOT, env: process.env }
|
||||
)
|
||||
await run(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'scripts', 'release', 'r2', 'finalize-windows.mjs'), '--tag', releaseTag],
|
||||
{ cwd: ROOT, env: process.env }
|
||||
)
|
||||
}
|
||||
await verifyPublicReleaseFragment(candidate.verified.fragment, publicBaseUrl)
|
||||
await verifyReleaseStorageFragment(candidate.verified.fragment, inspection.awsExecutable)
|
||||
if (!(await isReleaseChannelCurrent(candidate.verified.fragment, publicBaseUrl))) {
|
||||
throw new Error('public Windows release channel does not match the candidate')
|
||||
}
|
||||
await github.publish(githubRelease)
|
||||
await markCandidatePublished({ tag: releaseTag, platform: 'windows-x64' })
|
||||
await cleanupLocalTestLogsSafely()
|
||||
await cleanupLocalCandidates()
|
||||
process.stdout.write(`[release] Windows ${releaseTag} published from ${candidate.bundlePath}\n`)
|
||||
process.stdout.write(`[release] Windows ${releaseTag} published to ${storage.label} and GitHub\n`)
|
||||
@@ -140,7 +140,6 @@ describe('app update publish config', () => {
|
||||
macFinalize,
|
||||
tagContract,
|
||||
storageConfig,
|
||||
windowsShared,
|
||||
uploadFragment
|
||||
] = await Promise.all([
|
||||
readFile(path.resolve('scripts/release/windows.mjs'), 'utf8'),
|
||||
@@ -149,7 +148,6 @@ describe('app update publish config', () => {
|
||||
readFile(path.resolve('scripts/release/r2/finalize-macos.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/tag.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/storage/config.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/shared/windows.mjs'), 'utf8'),
|
||||
readFile(path.resolve('scripts/release/r2/upload-fragment.mjs'), 'utf8')
|
||||
])
|
||||
|
||||
@@ -177,9 +175,6 @@ describe('app update publish config', () => {
|
||||
expect(macFinalize).not.toContain('release-status/macos/')
|
||||
expect(uploadFragment).toContain('fragmentKey')
|
||||
expect(uploadFragment).toContain("'no-store'")
|
||||
expect(windowsShared).toContain('fragmentKey')
|
||||
expect(windowsShared).toContain('artifactKey(fragment, file.name)')
|
||||
expect(windowsShared).not.toContain('file.storageName')
|
||||
expect(tagContract).toContain('/^(mac|windows)-v')
|
||||
expect(windowsRelease).toContain('assertLivePublishAllowed')
|
||||
expect(macRelease).toContain('assertLivePublishAllowed')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createGitHubReleasePublisher } from '../../../../scripts/release/github.mjs'
|
||||
import {
|
||||
createReleaseBundle,
|
||||
verifyReleaseBundle
|
||||
@@ -98,3 +100,196 @@ describe('release artifact bundle', () => {
|
||||
await expect(verifyReleaseBundle(bundlePath)).rejects.toThrow('release asset size changed')
|
||||
})
|
||||
})
|
||||
|
||||
type GitHubAsset = {
|
||||
name: string
|
||||
state: string
|
||||
size: number
|
||||
digest: string
|
||||
browser_download_url: string
|
||||
}
|
||||
type GitHubRelease = {
|
||||
id: number
|
||||
tag_name: string
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
assets: GitHubAsset[]
|
||||
html_url: string
|
||||
}
|
||||
|
||||
const githubHarness = () => {
|
||||
const state = {
|
||||
commit: '0123456789012345678901234567890123456789',
|
||||
release: undefined as GitHubRelease | undefined,
|
||||
failUpload: '',
|
||||
failPublication: false,
|
||||
writes: [] as string[],
|
||||
bytes: new Map<string, Buffer>()
|
||||
}
|
||||
const execute = async (command: string, args: string[]) => {
|
||||
expect(command).toBe('gh')
|
||||
if (args[0] === 'release' && args[1] === 'view') {
|
||||
if (!state.release)
|
||||
throw Object.assign(new Error('not found'), { stderr: 'release not found' })
|
||||
return { stdout: JSON.stringify({ databaseId: state.release.id }) }
|
||||
}
|
||||
if (args[0] === 'api') {
|
||||
const endpoint = args.at(-1)
|
||||
if (endpoint === 'repos/Yoan98/Ncurator') {
|
||||
return { stdout: JSON.stringify({ private: false, permissions: { push: true } }) }
|
||||
}
|
||||
if (endpoint?.includes('/git/ref/')) {
|
||||
return { stdout: JSON.stringify({ object: { type: 'tag', sha: 'annotated-tag' } }) }
|
||||
}
|
||||
if (endpoint?.includes('/git/tags/')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
object: { type: 'commit', sha: state.commit },
|
||||
message: '修复文件读取'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!state.release) throw Object.assign(new Error('not found'), { stderr: '(HTTP 404)' })
|
||||
return { stdout: JSON.stringify(state.release) }
|
||||
}
|
||||
state.writes.push(args[1])
|
||||
if (args[1] === 'create') {
|
||||
expect(args).toContain('--verify-tag')
|
||||
expect(args).toContain('--draft')
|
||||
state.release = {
|
||||
id: 7,
|
||||
tag_name: args[2],
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [],
|
||||
html_url: `https://github.com/Yoan98/Ncurator/releases/tag/${args[2]}`
|
||||
}
|
||||
} else if (args[1] === 'upload') {
|
||||
expect(args).not.toContain('--clobber')
|
||||
const filePath = args.at(-1)!
|
||||
const name = path.basename(filePath)
|
||||
if (state.failUpload === name) throw new Error('upload interrupted')
|
||||
const content = await fs.readFile(filePath)
|
||||
const url = `https://github.com/Yoan98/Ncurator/releases/download/${args[2]}/${encodeURIComponent(name)}`
|
||||
state.bytes.set(url, content)
|
||||
state.release!.assets.push({
|
||||
name,
|
||||
size: content.length,
|
||||
state: 'uploaded',
|
||||
digest: `sha256:${createHash('sha256').update(content).digest('hex')}`,
|
||||
browser_download_url: url
|
||||
})
|
||||
} else if (args[1] === 'edit') {
|
||||
if (state.failPublication) throw new Error('publication interrupted')
|
||||
state.release!.draft = false
|
||||
} else if (args[1] === 'delete') {
|
||||
state.release = undefined
|
||||
} else {
|
||||
throw new Error(`unexpected command: ${args.join(' ')}`)
|
||||
}
|
||||
return { stdout: '' }
|
||||
}
|
||||
const publisher = createGitHubReleasePublisher({
|
||||
execute,
|
||||
fetchImpl: async (url: string) => {
|
||||
const content = state.bytes.get(url)
|
||||
return new Response(content ? new Uint8Array(content) : null, { status: content ? 200 : 404 })
|
||||
}
|
||||
})
|
||||
return { state, publisher }
|
||||
}
|
||||
|
||||
const githubCandidate = async () => {
|
||||
const directory = await createFixture()
|
||||
const bundlePath = path.join(directory, 'release-bundle-windows-x64.json')
|
||||
await createReleaseBundle({
|
||||
fragmentPath: path.join(directory, 'windows-x64.json'),
|
||||
outputPath: bundlePath,
|
||||
commit: '0123456789012345678901234567890123456789'
|
||||
})
|
||||
return { directory, state: { status: 'ready' }, verified: await verifyReleaseBundle(bundlePath) }
|
||||
}
|
||||
|
||||
describe('GitHub release publication', () => {
|
||||
const previousConfirmation = process.env.NCURATOR_ALLOW_LIVE_PUBLISH
|
||||
beforeEach(() => {
|
||||
process.env.NCURATOR_ALLOW_LIVE_PUBLISH = 'publish-to-production'
|
||||
})
|
||||
afterEach(() => {
|
||||
if (previousConfirmation === undefined) delete process.env.NCURATOR_ALLOW_LIVE_PUBLISH
|
||||
else process.env.NCURATOR_ALLOW_LIVE_PUBLISH = previousConfirmation
|
||||
})
|
||||
|
||||
it('uploads only the tested installer and checksum, then publishes verified public bytes', async () => {
|
||||
const { state, publisher } = githubHarness()
|
||||
const prepared = await publisher.prepare(await githubCandidate())
|
||||
expect(state.release?.draft).toBe(true)
|
||||
expect(state.release?.assets.map((asset) => asset.name)).toEqual([
|
||||
'馆长-0.1.21-Windows-x64.exe',
|
||||
'SHA256SUMS'
|
||||
])
|
||||
await publisher.publish(prepared)
|
||||
expect(state.release?.draft).toBe(false)
|
||||
const writes = [...state.writes]
|
||||
await publisher.publish(await publisher.prepare(await githubCandidate()))
|
||||
expect(state.writes).toEqual(writes)
|
||||
})
|
||||
|
||||
it('stops before any write if source identity or the live gate is invalid', async () => {
|
||||
const { state, publisher } = githubHarness()
|
||||
state.commit = 'f'.repeat(40)
|
||||
await expect(publisher.prepare(await githubCandidate())).rejects.toThrow('does not match')
|
||||
expect(state.writes).toEqual([])
|
||||
delete process.env.NCURATOR_ALLOW_LIVE_PUBLISH
|
||||
await expect(publisher.prepare(await githubCandidate())).rejects.toThrow(
|
||||
'live publish is locked'
|
||||
)
|
||||
expect(state.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('resumes an interrupted upload without overwriting the existing installer', async () => {
|
||||
const { state, publisher } = githubHarness()
|
||||
const candidate = await githubCandidate()
|
||||
state.failUpload = 'SHA256SUMS'
|
||||
await expect(publisher.prepare(candidate)).rejects.toThrow('upload interrupted')
|
||||
expect(state.release?.assets).toHaveLength(1)
|
||||
state.failUpload = ''
|
||||
await publisher.publish(await publisher.prepare(candidate))
|
||||
expect(state.release?.assets).toHaveLength(2)
|
||||
expect(state.writes.filter((write) => write === 'create')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resumes publication after the website stage without creating a second release', async () => {
|
||||
const { state, publisher } = githubHarness()
|
||||
const candidate = await githubCandidate()
|
||||
const prepared = await publisher.prepare(candidate)
|
||||
state.failPublication = true
|
||||
await expect(publisher.publish(prepared)).rejects.toThrow('publication interrupted')
|
||||
state.failPublication = false
|
||||
await publisher.publish(await publisher.prepare(candidate))
|
||||
expect(state.release?.draft).toBe(false)
|
||||
expect(state.writes.filter((write) => write === 'upload')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses mismatched existing assets and detects corrupt public downloads', async () => {
|
||||
const { state, publisher } = githubHarness()
|
||||
const candidate = await githubCandidate()
|
||||
const prepared = await publisher.prepare(candidate)
|
||||
state.release!.assets[0].digest = `sha256:${'f'.repeat(64)}`
|
||||
await expect(publisher.prepare(candidate)).rejects.toThrow('asset hash differs')
|
||||
state.release!.assets[0].digest = `sha256:${candidate.verified.bundle.files[0].sha256}`
|
||||
state.bytes.set(state.release!.assets[0].browser_download_url, Buffer.from('corrupted'))
|
||||
await expect(publisher.publish(prepared)).rejects.toThrow('public download hash differs')
|
||||
})
|
||||
|
||||
it('deletes unpublished drafts on abort and protects published releases', async () => {
|
||||
const { publisher } = githubHarness()
|
||||
const candidate = await githubCandidate()
|
||||
await publisher.prepare(candidate)
|
||||
await publisher.deleteDraft(candidate.verified.bundle.tag)
|
||||
await publisher.publish(await publisher.prepare(candidate))
|
||||
await expect(publisher.deleteDraft(candidate.verified.bundle.tag)).rejects.toThrow(
|
||||
'refuse to abort'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
assertCandidatePublishable,
|
||||
cleanupSupersededReleaseCandidates,
|
||||
materializeReleaseCandidate,
|
||||
markCandidatePublished,
|
||||
readReleaseCandidate,
|
||||
recordCandidateE2E,
|
||||
resolveCandidateDirectory
|
||||
@@ -135,6 +136,21 @@ describe('retained release candidate', () => {
|
||||
expect(rejected.bundlePath).toBe(prepared.bundlePath)
|
||||
})
|
||||
|
||||
it('permits verified published candidates only for an explicit publish retry', async () => {
|
||||
await materializeReleaseCandidate({ bundlePath: await createBundle() })
|
||||
await recordCandidateE2E({ tag: 'windows-v0.1.21', platform: 'windows-x64', status: 'passed' })
|
||||
await markCandidatePublished({ tag: 'windows-v0.1.21', platform: 'windows-x64' })
|
||||
const request = {
|
||||
tag: 'windows-v0.1.21',
|
||||
platform: 'windows-x64',
|
||||
commit: '0123456789012345678901234567890123456789'
|
||||
}
|
||||
await expect(assertCandidatePublishable(request)).rejects.toThrow('is published')
|
||||
await expect(
|
||||
assertCandidatePublishable({ ...request, allowPublished: true })
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('allows an explicitly justified E2E skip to become publishable', async () => {
|
||||
const bundlePath = await createBundle()
|
||||
await materializeReleaseCandidate({ bundlePath })
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findLiveChannelsForTag } from '../../../../scripts/release/shared/live-tag.mjs'
|
||||
import {
|
||||
findLiveChannelsForTag,
|
||||
isReleaseChannelCurrent
|
||||
} from '../../../../scripts/release/shared/live-tag.mjs'
|
||||
|
||||
describe('release live tag detection', () => {
|
||||
it('reports channels whose latest manifest points at the tag', async () => {
|
||||
@@ -27,4 +30,72 @@ describe('release live tag detection', () => {
|
||||
})
|
||||
await expect(findLiveChannelsForTag('mac-v1.0.0', fetchImpl)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it.each(['windows', 'mac'] as const)(
|
||||
'verifies the %s manifest and relative update URL against the candidate',
|
||||
async (family) => {
|
||||
const base = 'https://download.guanzhangai.cn'
|
||||
const tag = `${family}-v1.0.0`
|
||||
const folder = family === 'windows' ? 'windows' : 'macos'
|
||||
const installerName =
|
||||
family === 'windows' ? '馆长-1.0.0-Windows-x64.exe' : '馆长-1.0.0-macos-arm64.dmg'
|
||||
const installer = {
|
||||
role: 'installer',
|
||||
name: installerName,
|
||||
size: 123,
|
||||
sha512: 'installer-hash'
|
||||
}
|
||||
const update =
|
||||
family === 'windows'
|
||||
? installer
|
||||
: { role: 'update', name: 'Ncurator-mac-arm64.zip', size: 456, sha512: 'zip-hash' }
|
||||
const fragment = {
|
||||
tag,
|
||||
platform: family === 'windows' ? 'windows-x64' : 'macos-arm64',
|
||||
files: [installer, ...(family === 'mac' ? [update] : [])]
|
||||
}
|
||||
const manifest = {
|
||||
version: '1.0.0',
|
||||
tag,
|
||||
downloads: {
|
||||
[family === 'windows' ? 'x64' : 'arm64']: {
|
||||
...installer,
|
||||
url: `${base}/artifacts/${folder}/${tag}/${installerName}`
|
||||
}
|
||||
}
|
||||
}
|
||||
let metadata = `version: 1.0.0\nfiles:\n - url: ../../artifacts/${folder}/${tag}/${update.name}\n size: ${update.size}\n sha512: ${update.sha512}\n`
|
||||
const fetchImpl = async (url: string) =>
|
||||
new Response(url.includes('.json') ? JSON.stringify(manifest) : metadata)
|
||||
await expect(isReleaseChannelCurrent(fragment, base, fetchImpl)).resolves.toBe(true)
|
||||
metadata = metadata.replace(`/${tag}/`, '/old-tag/')
|
||||
await expect(isReleaseChannelCurrent(fragment, base, fetchImpl)).resolves.toBe(false)
|
||||
metadata = metadata.replace('version: 1.0.0', 'version: 1.0.1')
|
||||
await expect(isReleaseChannelCurrent(fragment, base, fetchImpl)).rejects.toThrow(
|
||||
'refuse to downgrade'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('distinguishes an absent channel from a failed lookup', async () => {
|
||||
const fragment = {
|
||||
tag: 'windows-v1.0.0',
|
||||
platform: 'windows-x64',
|
||||
files: [{ role: 'installer', name: 'installer.exe', size: 123, sha512: 'hash' }]
|
||||
}
|
||||
await expect(
|
||||
isReleaseChannelCurrent(
|
||||
fragment,
|
||||
'https://example.com',
|
||||
async () => new Response(null, { status: 404 })
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
await expect(
|
||||
isReleaseChannelCurrent(
|
||||
fragment,
|
||||
'https://example.com',
|
||||
async () => new Response(null, { status: 503 })
|
||||
)
|
||||
).rejects.toThrow('HTTP 503')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user