diff --git a/.gitattributes b/.gitattributes index 0de5840..36196e5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,7 @@ deploy/entrypoint.sh text eol=lf + +# 正式字体资产保存在仓库固定目录,但二进制内容交由 Git LFS 管理。 +font-packs/assets/**/*.ttf filter=lfs diff=lfs merge=lfs -text +font-packs/assets/**/*.otf filter=lfs diff=lfs merge=lfs -text +font-packs/assets/**/*.woff filter=lfs diff=lfs merge=lfs -text +font-packs/assets/**/*.woff2 filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 9222722..2c32fe2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,10 @@ Thumbs.db tmp/ playwright-report/ test-results/ + +# 构建、门禁与正式发版产物只保留在本机,不进入 Git。 output/ /apps/desktop/out/ /apps/desktop/.runtime/ +/release/ *.tsbuildinfo diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5f28c15..a5072c5 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -115,6 +115,18 @@ GitHub、Clean、Pixyll 和 Whitey,默认主题仍为 GitHub。为确保覆盖 `typora-whitey` 等内部主题 ID、目录名和配置键保持不变;来源说明、作者 字段及许可证审计信息也继续保留。 +`v0.6.0` 发布收口阶段 D 已建立统一发版规则。正式分发文件只允许归集到 +被 Git 忽略的 `release/v/`;统一脚本从 Desktop、字体包和 +版本化 Docker 镜像生成安装器、便携 ZIP、字体附件、离线镜像 tar.gz、 +Web 部署 ZIP、发行说明、机器可读清单及 SHA-256 校验文件,先写入临时 +目录并在全部成功后原子落盘,既有版本目录禁止覆盖。正式字体资产迁入 +`font-packs/assets///` 并由 Git LFS 管理,构建器继续严格校验 +冻结字节数、哈希、内部字族名和嵌入权限。本次 `release/v0.6.0` 的 8 项 +校验和全部通过,707,722,584 字节的离线镜像归档已由 `docker load` 重新 +导入,镜像 ID 保持 +`sha256:53bfbc9592542d4ffe9ec3017c401c6d49a28c4ee993c6c5fc00536b1d01978a`, +运行容器健康状态为 `healthy`。 + `v0.6.0` 阶段 3 已完成 DOCX 共享协议:`packages/core` 统一定义请求 Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和 安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用 @@ -1370,9 +1382,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;应用层已将同一 解析结果用于主题 CSS 的 WOFF2 和 DOCX 的静态 TrueType,发行资产与 安装逻辑留在 FP3。 -- `packages/font-pack-builder` 负责从仓库外冻结资源构建版本化字体包, +- `packages/font-pack-builder` 负责从仓库内 Git LFS 冻结资源构建版本化字体包, 校验输入哈希、字体格式、内部元数据、嵌入权限、同版本不可变性并生成 - 可供注册器再次验证的构建报告;字体二进制和产物目录均不进入 Git。 + 可供注册器再次验证的构建报告;正式字体二进制进入 LFS,构建产物、门禁 + 报告和 `release/` 均不进入 Git。 - `packages/application/src/docx-export-service.ts` 负责 DOCX capability、 并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。 - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron @@ -1456,6 +1469,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 哈希门禁、许可证保留、打包接入、离线复验和真实 DOCX 冒烟; - 发布收口阶段 C:已完成确定性字体 ZIP、MorphDoc 字体安装器命名、 Desktop 同目录可选附件、Docker 镜像内置字体和真实 DOCX 发行门禁; +- 发布收口阶段 D:已完成统一 `release/v` 产物目录、Docker 离线 + 镜像封包、部署 ZIP、清单与校验和规则,并将正式字体纳入 Git LFS; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..61fafe3 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,71 @@ +# 发版规则 + +## 唯一产物目录 + +所有正式分发文件必须统一归集到仓库根目录下的: + +```text +release/v/ +``` + +`release/`、`output/`、`apps/desktop/out/`、`apps/desktop/.runtime/`、 +`tmp/` 以及门禁报告均是本地中间产物或发布产物,必须保持 Git 忽略。 +大型二进制不得直接提交到普通 Git 历史。 + +正式目录只能由 `npm run release:stage` 生成。脚本先写入同一文件系统中的 +`.stage-*` 临时目录,完成镜像封包、清单和校验和后再原子改名;目标版本 +目录已经存在时必须拒绝覆盖。 + +## 标准目录内容 + +每个版本至少包含: + +```text +release/v/ +├── MorphDoc--x86_64-Setup.exe +├── MorphDoc--x86_64.zip +├── MorphDoc-FontPack---x86_64-Setup.exe +├── MorphDoc-FontPack--.zip +├── MorphDoc-Web--image.tar.gz +├── MorphDoc-Web--deploy.zip +├── manifest.json +├── SHA256SUMS.txt +└── RELEASE-NOTES.md +``` + +Docker 镜像必须通过 `docker save` 导出并使用 gzip 压缩。Web 部署 ZIP 必须 +包含 Compose、Nginx、环境变量示例和离线加载说明。`manifest.json` 记录 +版本、Git 提交、镜像 ID、文件大小和 SHA-256;`SHA256SUMS.txt` 用于分发后 +独立校验。 + +## 正式字体资产 + +正式字体固定存放于: + +```text +font-packs/assets/// +``` + +TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前必须完成 +许可证、来源、内部字族名、文档嵌入权限、字节数和 SHA-256 审查;任何内容 +变化都必须提升字体包 SemVer。克隆后需要先执行 `git lfs pull`,构建器不会 +下载或使用系统字体代替缺失资产。 + +## 发版步骤 + +1. 完成全量测试、类型检查、生产构建和视觉门禁。 +2. 构建 Desktop 安装包、便携 ZIP 和字体附件。 +3. 构建并验证版本化 Docker 镜像。 +4. 准备 `docs/releases/v.md`。 +5. 执行: + + ```powershell + npm run release:stage -- --version --image md-to-pdf:v + ``` + +6. 逐项核验 `SHA256SUMS.txt`,并从离线归档重新加载 Docker 镜像。 +7. 创建内容完整的 `release:` 提交。 +8. 创建 annotated `v` 标签,标签说明与发布提交正文一致。 + +安装器公开分发前应完成 Authenticode 签名;未签名产物只能按内部发布风险 +规则分发。 diff --git a/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md new file mode 100644 index 0000000..9c93134 --- /dev/null +++ b/docs/releases/v0.6.0.md @@ -0,0 +1,38 @@ +# MorphDoc · 墨呈 v0.6.0 + +## 核心更新 + +- 新增可编辑 DOCX 导出,保留纸张、页边距、字体、段落、代码块、图片、 + Mermaid、ECharts、封面、页眉页脚和页码配置。 +- 建立通用主题到 DOCX 样式翻译引擎,14 套内置主题通过真实 Word/WPS + 无修复打开、编辑和逐页视觉门禁。 +- 新增可扩展 Markdown 基础工具栏,支持标题、强调、代码、引用、多行列表、 + 任务列表、分隔线及 M×N 表格。 +- PDF 与 DOCX 统一收口到“导出”子菜单,并提供独立顶部进度提示。 + +## 发行与兼容性 + +- 产品更名为“墨呈”,英文名 MorphDoc;保留原 NSIS 安装身份并迁移到新的 + 默认安装目录、快捷方式和开始菜单目录。 +- Desktop 内置固定 Pandoc 3.9.0.2 最小运行时;字体包可在安装向导中选择, + 也提供独立安装器与 ZIP。 +- Docker 镜像内置固定 Chromium、Pandoc 与正式字体包,支持完全离线部署。 +- 正式字体资产转由仓库固定目录和 Git LFS 管理。 + +## 问题修复 + +- 修复精确预览放大后右侧缺少固定留白的问题。 +- 修复保存、另存为和导出的默认路径优先级。 +- 修复 Desktop DOCX 图片捕获可能超时的问题。 +- 修复品牌改名后安装器仍继承旧安装目录的问题。 +- 统一主题展示名称并移除 Typora 字样,默认主题保持 GitHub。 + +## 验证结论 + +- 全项目测试、类型检查、生产构建和空白检查通过。 +- 14 套主题生产 PDF/DOCX 与六组纸张、方向、页边距视觉矩阵通过。 +- Web API、Desktop ZIP、覆盖安装后的真实安装版均完成 PDF/DOCX 导出。 +- Word/WPS 可编辑性、字体嵌入、封面独立分页和页眉页脚位置门禁通过。 + +当前 Windows 安装器未进行 Authenticode 签名,适用于公司内部发布;公开 +网络分发前建议增加代码签名。 diff --git a/font-packs/README.md b/font-packs/README.md index 216f6d1..20a382d 100644 --- a/font-packs/README.md +++ b/font-packs/README.md @@ -1,21 +1,24 @@ # 可选字体包构建 -本目录只保存字体包配方、许可证和来源说明,不保存字体二进制。普通 -Desktop 主安装包不包含字体;组合 Desktop 发行把字体作为同目录可选附件, -Docker 发行构建则把本次受校验的字体包写入镜像层。 +本目录保存字体包配方、许可证、来源说明和正式冻结字体资产。字体二进制 +位于固定的 `font-packs/assets/`,统一由 Git LFS 管理,普通 Git 对象只保存 +LFS 指针。普通 Desktop 主安装包不包含字体;组合 Desktop 发行把字体作为 +同目录可选附件,Docker 发行构建则把本次受校验的字体包写入镜像层。 ## 源目录 -发布人员需要在 Git 忽略的本地目录中准备配方声明的冻结资源。默认目录为: +正式发布字体必须先完成许可证、来源、内部字族名、嵌入权限和哈希审查, +随后固定存放在以下版本化仓库目录: ```text -.local/font-pack-sources/mdtp-serif-sc/1.0.0/ +font-packs/assets/mdtp-serif-sc/1.0.0/ ├── MdTPSerifSC-Regular.ttf ├── MdTPSerifSC-Regular.woff2 ├── MdTPSerifSC-Bold.ttf └── MdTPSerifSC-Bold.woff2 ``` +该目录中的 `ttf/otf/woff/woff2` 由 `.gitattributes` 强制使用 Git LFS。 构建器不会联网下载、转换或自动替换字体。每个输入文件都必须与配方声明的 字节数和 SHA-256 完全一致;TTF 还必须是无需运行时规范化的原生静态 TrueType,并具有可安装或可编辑的文档嵌入权限。 @@ -33,6 +36,9 @@ npm run verify:font-pack npm run build:font-pack -- --source-root D:/font-pack-sources/mdtp-serif-sc/1.0.0 ``` +克隆仓库后应先执行 `git lfs pull`。缺少 LFS 内容时,字体构建会因文件大小 +或 SHA-256 不匹配而拒绝继续,不允许使用系统字体或相近字体静默替代。 + 默认运行时字体包根目录为 `output/font-packs/root`,构建报告写入 `output/font-packs/build-report.json`,两者均被 Git 忽略。输出布局为: diff --git a/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Bold.ttf b/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Bold.ttf new file mode 100644 index 0000000..4dafc21 --- /dev/null +++ b/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Bold.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0bcd279ae061180e12ea0b7f2715de75bede67411373816495b35ade989b154 +size 14853848 diff --git a/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Bold.woff2 b/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Bold.woff2 new file mode 100644 index 0000000..17170e1 --- /dev/null +++ b/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Bold.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cce565f7504b22c9c08bbcb342f178ab2f730a202a76913afe77a5bd49125961 +size 5925732 diff --git a/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Regular.ttf b/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Regular.ttf new file mode 100644 index 0000000..69b65b1 --- /dev/null +++ b/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bc4775662cfdb8bcbd59ddfc529852b7f72ca4906f339950ee9d7a44b844942 +size 14854872 diff --git a/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Regular.woff2 b/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Regular.woff2 new file mode 100644 index 0000000..098b7ae --- /dev/null +++ b/font-packs/assets/mdtp-serif-sc/1.0.0/MdTPSerifSC-Regular.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1887c69d28ffba5a9f5eb5f8240c12264ea4b8e807f27ef45e308fb53b8e3ac +size 5732996 diff --git a/package.json b/package.json index ff9af24..e4324c2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "build:font-pack:windows": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js windows-installer", "build:font-pack:archive": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js archive", "desktop:make:with-font-pack": "npm run build:font-pack:windows && npm run build:font-pack:archive && node scripts/build-desktop-with-font-pack.mjs", + "release:stage": "node scripts/stage-release.mjs", + "test:release-stage": "node --test scripts/stage-release.test.mjs", "theme:import-typora": "node scripts/import-typora-theme.mjs", "verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine", "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", @@ -44,7 +46,7 @@ "verify:docx-page-decorations": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-page-decoration-visuals.mjs", "verify:docx-media-visuals": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-media-visuals.mjs", "verify:docx-layout-visual-matrix": "npm run verify:docx-r4-matrix && npm run build -w @md-to-pdf/document-visual-diff && node scripts/verify-docx-layout-visual-matrix.mjs", - "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", + "test": "npm run test:release-stage && npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/font-pack-registry && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/font-pack-builder && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" }, "engines": { diff --git a/packages/font-pack-builder/src/cli.ts b/packages/font-pack-builder/src/cli.ts index 99c9203..37633ea 100644 --- a/packages/font-pack-builder/src/cli.ts +++ b/packages/font-pack-builder/src/cli.ts @@ -44,7 +44,7 @@ async function main() { option( values, "source-root", - ".local/font-pack-sources/mdtp-serif-sc/1.0.0" + "font-packs/assets/mdtp-serif-sc/1.0.0" ) ), outputRoot: resolve( diff --git a/scripts/stage-release.mjs b/scripts/stage-release.mjs new file mode 100644 index 0000000..b4382e6 --- /dev/null +++ b/scripts/stage-release.mjs @@ -0,0 +1,286 @@ +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { + copyFile, + mkdir, + open, + readFile, + rename, + rm, + stat, + writeFile +} from "node:fs/promises"; +import { createReadStream, createWriteStream } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; +import { createGzip } from "node:zlib"; +import { zipSync } from "fflate"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export function normalizeVersion(value) { + const normalized = String(value ?? "").trim().replace(/^v/u, ""); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(normalized)) { + throw new Error(`无效发布版本:${value ?? ""}`); + } + return normalized; +} + +export function parseArguments(values) { + const result = new Map(); + for (let index = 0; index < values.length; index += 2) { + const key = values[index]; + const value = values[index + 1]; + if (!key?.startsWith("--") || !value || value.startsWith("--")) { + throw new Error(`无效命令参数:${key ?? ""}`); + } + result.set(key.slice(2), value); + } + return result; +} + +async function sha256(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) { + hash.update(chunk); + } + return hash.digest("hex"); +} + +async function run(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: repositoryRoot, + env: process.env, + stdio: options.capture ? ["ignore", "pipe", "inherit"] : "inherit", + windowsHide: true + }); + let stdout = ""; + if (options.capture) { + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + } + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolvePromise(stdout.trim()); + } else { + reject( + new Error( + `${command} 执行失败:${signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`}` + ) + ); + } + }); + }); +} + +function deploymentFiles(version) { + return { + "compose.yaml": readFile(join(repositoryRoot, "deploy", "compose.yaml")), + "nginx.conf": readFile(join(repositoryRoot, "deploy", "nginx.conf")), + "README.md": readFile(join(repositoryRoot, "deploy", "README.md")), + ".env.example": Promise.resolve( + Buffer.from( + [ + `MD_TO_PDF_IMAGE=md-to-pdf:v${version}`, + "MD_TO_PDF_APP_VERSION=" + version, + "MD_TO_PDF_PORT=8080", + "PDF_CONCURRENCY=1", + "PDF_MAX_QUEUE=4", + "PDF_TIMEOUT_MS=120000", + "" + ].join("\n"), + "utf8" + ) + ), + "OFFLINE-README.md": Promise.resolve( + Buffer.from( + [ + `# MorphDoc Web v${version} 离线部署`, + "", + `1. 将 \`MorphDoc-Web-${version}-image.tar.gz\` 放在本目录上一级。`, + `2. 执行 \`docker load --input MorphDoc-Web-${version}-image.tar.gz\`。`, + "3. 复制 `.env.example` 为 `.env` 并按需调整端口与并发。", + "4. 执行 `docker compose --env-file .env up -d --no-build`。", + "5. 访问 `/api/health`,确认状态为 `ok` 后再开放服务。", + "", + "镜像已经内置固定 Pandoc、Chromium 与正式字体包,不需要额外下载。", + "" + ].join("\n"), + "utf8" + ) + ) + }; +} + +export async function createDeploymentArchive(version, outputPath) { + const entries = {}; + for (const [name, content] of Object.entries(deploymentFiles(version))) { + entries[`MorphDoc-Web-${version}-deploy/${name}`] = new Uint8Array( + await content + ); + } + await writeFile(outputPath, zipSync(entries, { level: 9 })); +} + +export async function describeArtifacts(directory, names) { + const entries = []; + for (const name of names) { + const path = join(directory, name); + const info = await stat(path); + entries.push({ + name, + bytes: info.size, + sha256: await sha256(path) + }); + } + return entries; +} + +export function checksumText(entries) { + return `${entries.map((entry) => `${entry.sha256} ${entry.name}`).join("\n")}\n`; +} + +async function assertReadableFile(path) { + const handle = await open(path, "r"); + await handle.close(); + const info = await stat(path); + if (!info.isFile() || info.size === 0) { + throw new Error(`发布源文件无效:${relative(repositoryRoot, path)}`); + } +} + +export async function stageRelease(options = {}) { + const rootManifest = JSON.parse( + await readFile(join(repositoryRoot, "package.json"), "utf8") + ); + const version = normalizeVersion(options.version ?? rootManifest.version); + const image = options.image ?? `md-to-pdf:v${version}`; + const releaseRoot = resolve(options.releaseRoot ?? join(repositoryRoot, "release")); + const finalDirectory = join(releaseRoot, `v${version}`); + const stageDirectory = join( + releaseRoot, + `.stage-v${version}-${randomUUID()}` + ); + try { + await stat(finalDirectory); + throw new Error(`发布目录已存在,拒绝覆盖:${finalDirectory}`); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + const desktopDirectory = join( + repositoryRoot, + "apps", + "desktop", + "out", + `v${version}` + ); + const desktopNames = [ + `MorphDoc-${version}-x86_64-Setup.exe`, + `MorphDoc-${version}-x86_64.zip`, + "MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe", + "MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip" + ]; + const releaseNotesSource = join( + repositoryRoot, + "docs", + "releases", + `v${version}.md` + ); + for (const name of desktopNames) { + await assertReadableFile(join(desktopDirectory, name)); + } + await assertReadableFile(releaseNotesSource); + + await mkdir(stageDirectory, { recursive: true }); + try { + for (const name of desktopNames) { + await copyFile(join(desktopDirectory, name), join(stageDirectory, name)); + } + await copyFile(releaseNotesSource, join(stageDirectory, "RELEASE-NOTES.md")); + + const deployName = `MorphDoc-Web-${version}-deploy.zip`; + await createDeploymentArchive(version, join(stageDirectory, deployName)); + + const imageName = `MorphDoc-Web-${version}-image.tar.gz`; + const imageOutput = join(stageDirectory, imageName); + const imageTar = join(stageDirectory, `.image-${randomUUID()}.tar`); + await run("docker", ["image", "inspect", image], { capture: true }); + await run("docker", ["save", "--output", imageTar, image]); + await pipeline( + createReadStream(imageTar), + createGzip({ level: 9 }), + createWriteStream(imageOutput) + ); + await rm(imageTar, { force: true }); + + const primaryNames = [ + ...desktopNames, + imageName, + deployName, + "RELEASE-NOTES.md" + ]; + const artifacts = await describeArtifacts(stageDirectory, primaryNames); + const gitCommit = await run("git", ["rev-parse", "HEAD"], { capture: true }); + const imageId = JSON.parse( + await run("docker", ["image", "inspect", image, "--format", "{{json .Id}}"], { + capture: true + }) + ); + const manifest = { + schemaVersion: 1, + product: "MorphDoc", + displayName: "墨呈", + version, + gitCommit, + docker: { image, imageId }, + artifacts + }; + await writeFile( + join(stageDirectory, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8" + ); + const checksumEntries = await describeArtifacts(stageDirectory, [ + ...primaryNames, + "manifest.json" + ]); + await writeFile( + join(stageDirectory, "SHA256SUMS.txt"), + checksumText(checksumEntries), + "utf8" + ); + + await mkdir(releaseRoot, { recursive: true }); + await rename(stageDirectory, finalDirectory); + return { version, image, directory: finalDirectory, artifacts }; + } catch (error) { + await rm(stageDirectory, { recursive: true, force: true }); + throw error; + } +} + +async function main() { + const values = parseArguments(process.argv.slice(2)); + const result = await stageRelease({ + version: values.get("version"), + image: values.get("image") + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.stack ?? error.message : String(error)}\n` + ); + process.exitCode = 1; + }); +} diff --git a/scripts/stage-release.test.mjs b/scripts/stage-release.test.mjs new file mode 100644 index 0000000..1361016 --- /dev/null +++ b/scripts/stage-release.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { unzipSync } from "fflate"; +import { + checksumText, + createDeploymentArchive, + normalizeVersion, + parseArguments +} from "./stage-release.mjs"; + +test("normalizeVersion 接受语义版本并移除 v 前缀", () => { + assert.equal(normalizeVersion("v0.6.0"), "0.6.0"); + assert.equal(normalizeVersion("1.2.3-beta.1"), "1.2.3-beta.1"); + assert.throws(() => normalizeVersion("latest"), /无效发布版本/u); +}); + +test("parseArguments 拒绝不完整参数", () => { + assert.deepEqual( + [...parseArguments(["--version", "0.6.0", "--image", "test:1"])], + [ + ["version", "0.6.0"], + ["image", "test:1"] + ] + ); + assert.throws(() => parseArguments(["--version"]), /无效命令参数/u); +}); + +test("checksumText 生成标准 SHA256SUMS 格式", () => { + assert.equal( + checksumText([{ name: "artifact.zip", sha256: "abc" }]), + "abc artifact.zip\n" + ); +}); + +test("Web 离线部署 ZIP 包含 Compose 和导入说明", async () => { + const directory = await mkdtemp(join(tmpdir(), "morphdoc-release-test-")); + const output = join(directory, "deploy.zip"); + try { + await createDeploymentArchive("0.6.0", output); + const entries = unzipSync(new Uint8Array(await readFile(output))); + const names = Object.keys(entries).sort(); + assert.ok(names.includes("MorphDoc-Web-0.6.0-deploy/compose.yaml")); + assert.ok(names.includes("MorphDoc-Web-0.6.0-deploy/.env.example")); + assert.ok(names.includes("MorphDoc-Web-0.6.0-deploy/OFFLINE-README.md")); + assert.match( + Buffer.from( + entries["MorphDoc-Web-0.6.0-deploy/OFFLINE-README.md"] + ).toString("utf8"), + /docker load/u + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +});