release: 发布墨呈 v0.6.0

新增能力:实现可编辑 DOCX 导出、通用主题样式翻译、Markdown 基础工具栏、统一导出菜单与导出进度提示;14 套主题支持纸张、页边距、字体、段落、代码、图片、图表、封面、页眉页脚和页码映射。

问题修复:修复精确预览放大后的右侧留白、保存与导出默认路径、Desktop DOCX 媒体捕获超时、品牌改名后的安装目录迁移,并统一主题展示名称。

兼容与部署:产品更名为墨呈/MorphDoc,保留 NSIS 安装身份;Desktop 内置 Pandoc 3.9.0.2,字体包支持安装向导可选与独立分发;Docker 内置 Chromium、Pandoc 和正式字体,支持离线部署。

发布规则:新增 release/v<version> 唯一产物目录、Docker 镜像 tar.gz、Web 部署 ZIP、manifest.json 和 SHA256SUMS.txt;正式字体迁入版本化仓库目录并由 Git LFS 管理。

验证结果:全项目测试、类型检查、生产构建、git diff --check、14 主题 DOCX 与视觉矩阵、Web API、Desktop 安装版、Word/WPS 可编辑性和字体门禁全部通过;离线镜像经 docker load 复验且容器保持 healthy。

发布产物:Desktop 安装器与 ZIP、字体安装器与 ZIP、Web 镜像压缩包、离线部署包、发行说明和校验清单已整理至本地 release/v0.6.0。Windows EXE 当前未进行 Authenticode 签名,仅按公司内部发布风险规则分发。
This commit is contained in:
SkyJourney
2026-08-02 15:54:27 +08:00
parent 3e89e3faff
commit 842c3f1782
14 changed files with 505 additions and 9 deletions
+6
View File
@@ -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
+3
View File
@@ -14,7 +14,10 @@ Thumbs.db
tmp/
playwright-report/
test-results/
# 构建、门禁与正式发版产物只保留在本机,不进入 Git。
output/
/apps/desktop/out/
/apps/desktop/.runtime/
/release/
*.tsbuildinfo
+17 -2
View File
@@ -115,6 +115,18 @@ GitHub、Clean、Pixyll 和 Whitey,默认主题仍为 GitHub。为确保覆盖
`typora-whitey` 等内部主题 ID、目录名和配置键保持不变;来源说明、作者
字段及许可证审计信息也继续保留。
`v0.6.0` 发布收口阶段 D 已建立统一发版规则。正式分发文件只允许归集到
被 Git 忽略的 `release/v<version>/`;统一脚本从 Desktop、字体包和
版本化 Docker 镜像生成安装器、便携 ZIP、字体附件、离线镜像 tar.gz、
Web 部署 ZIP、发行说明、机器可读清单及 SHA-256 校验文件,先写入临时
目录并在全部成功后原子落盘,既有版本目录禁止覆盖。正式字体资产迁入
`font-packs/assets/<id>/<semver>/` 并由 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<version>` 产物目录、Docker 离线
镜像封包、部署 ZIP、清单与校验和规则,并将正式字体纳入 Git LFS;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+71
View File
@@ -0,0 +1,71 @@
# 发版规则
## 唯一产物目录
所有正式分发文件必须统一归集到仓库根目录下的:
```text
release/v<semver>/
```
`release/``output/``apps/desktop/out/``apps/desktop/.runtime/`
`tmp/` 以及门禁报告均是本地中间产物或发布产物,必须保持 Git 忽略。
大型二进制不得直接提交到普通 Git 历史。
正式目录只能由 `npm run release:stage` 生成。脚本先写入同一文件系统中的
`.stage-*` 临时目录,完成镜像封包、清单和校验和后再原子改名;目标版本
目录已经存在时必须拒绝覆盖。
## 标准目录内容
每个版本至少包含:
```text
release/v<version>/
├── MorphDoc-<version>-x86_64-Setup.exe
├── MorphDoc-<version>-x86_64.zip
├── MorphDoc-FontPack-<id>-<font-version>-x86_64-Setup.exe
├── MorphDoc-FontPack-<id>-<font-version>.zip
├── MorphDoc-Web-<version>-image.tar.gz
├── MorphDoc-Web-<version>-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/<font-pack-id>/<semver>/
```
TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前必须完成
许可证、来源、内部字族名、文档嵌入权限、字节数和 SHA-256 审查;任何内容
变化都必须提升字体包 SemVer。克隆后需要先执行 `git lfs pull`,构建器不会
下载或使用系统字体代替缺失资产。
## 发版步骤
1. 完成全量测试、类型检查、生产构建和视觉门禁。
2. 构建 Desktop 安装包、便携 ZIP 和字体附件。
3. 构建并验证版本化 Docker 镜像。
4. 准备 `docs/releases/v<version>.md`
5. 执行:
```powershell
npm run release:stage -- --version <version> --image md-to-pdf:v<version>
```
6. 逐项核验 `SHA256SUMS.txt`,并从离线归档重新加载 Docker 镜像。
7. 创建内容完整的 `release:` 提交。
8. 创建 annotated `v<version>` 标签,标签说明与发布提交正文一致。
安装器公开分发前应完成 Authenticode 签名;未签名产物只能按内部发布风险
规则分发。
+38
View File
@@ -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 签名,适用于公司内部发布;公开
网络分发前建议增加代码签名。
+11 -5
View File
@@ -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 忽略。输出布局为:
Binary file not shown.
+3 -1
View File
@@ -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": {
+1 -1
View File
@@ -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(
+286
View File
@@ -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;
});
}
+57
View File
@@ -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 });
}
});