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:
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user