Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### Added

- **Relay 部署契约**:新增默认只读的 `sync_relay_scripts.mjs --check` 与显式 Human-gated `--apply`;manifest 覆盖入口脚本及本地依赖,部署时 staging、逐文件备份、复检并在失败时回滚,Orchestrator automatic/manual relay 路径在推送前强制检查但绝不自动部署
- **Digest/relay 契约**:`orchestrator` 新增 `--digest`,默认读取 frontmatter `summary`,并在自动/手动 relay 推送命令中显式传给 `create_wechat_draft.mjs`
- **发布证据持久化**:草稿 create/update + `draft/get` 后自动写入 `audit.log` 与 `push-result.json`;relay 路径把证据带回文章 `publish/vN/`
- **CATALOG Backlink**:Orchestrator 可发现或显式接收 CATALOG/slug,成功后回写当前 id、`pushed-draft`、audit 指针,并保留被替换的旧 id
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,31 @@ node scripts/create_wechat_draft.mjs \

**方式 B:通过跳板机推送**(本地 IP 不在白名单时)

正式推送前先验证 relay 共享脚本与当前仓库一致:

```bash
npm run relay:check
# 等价命令:node scripts/sync_relay_scripts.mjs --check --json
```

这个检查覆盖 `create_wechat_draft.mjs` 及其仓库内本地依赖、逐文件
SHA-256、Node 语法和 `--digest` / `--source-path` / `--audit-out` /
`--push-result-out` / `--json` 参数面。Orchestrator 的自动与手动 relay
路径都会先执行该检查;发现漂移时直接非零退出,不会边推文章边改共享脚本。

只有获得 live mutation 授权后,才执行部署:

```bash
node scripts/sync_relay_scripts.mjs --apply --json
```

`--apply` 会先把完整 manifest 上传到 staging 路径,为每个现有文件创建
`.bak-<UTC时间>` 备份,再执行 staged promotion、hash/语法/参数复检;安装
失败会从同批备份恢复。需要可复现的备份名时可显式传
`--backup-tag <tag>`。relay 主机和共享目录只从 `.env` 的
`WECHAT_RELAY_HOST` / `WECHAT_RELAY_SCRIPTS_DIR` 读取,结构化输出不会打印
host 值或凭据。

推荐直接用 Orchestrator 的 `--auto-push`:

```bash
Expand All @@ -262,7 +287,9 @@ node scripts/orchestrator.mjs \
--auto-push
```

它会自动完成:创建远程目录 → SCP 上传 bundle(`.env` 单独传输)→ SSH 远程执行 `create_wechat_draft.mjs`。手动分步命令详见 SKILL.md Step 3。
它会自动完成:只读校验 relay manifest → 创建远程目录 → SCP 上传 bundle
(`.env` 单独传输)→ SSH 远程执行 `create_wechat_draft.mjs`。它不会自动
执行 `--apply`。手动分步命令详见 SKILL.md Step 3。

验证通过后,Orchestrator 必须把两份证据带回文章的同一
`publish/vN/`:
Expand Down
30 changes: 10 additions & 20 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,12 @@ metadata:
→ Step 3:推送 → Step 4:回检 → Step 5:核查
```

---

## Step 0:改写(khazix-writer)

`Skill: "khazix-writer"`。
⚠️ 删尾部模板「投稿或爆料…」整行,替换身份。正文 > 800 字至少 1 张图。
详细规则:`references/gotchas.md` #G01–#G05、#G34 + `references/writing-rules.md`。

---

## Step 0.5:Orchestrator(推荐)

```bash
Expand All @@ -44,8 +40,6 @@ node ${PIPELINE_HOME}/scripts/orchestrator.mjs \

⚠️ 发布优先用 Orchestrator,不要手动拆 render/bundle/push。`--digest` 不传时会读取 frontmatter `summary` 并传到 relay;`--qr` 必须传绝对路径,避免 footer QR 在 render/preflight 间被拼成错误相对路径。

---

## Step 1:渲染

```bash
Expand All @@ -58,16 +52,12 @@ node ${PIPELINE_HOME}/scripts/render_wechat_editorial.mjs \
⚠️ `--env` 和 `--lint-report-out` 必须指定。`--footer-qr` 一律使用绝对路径。禁止绕路用 inline import 替换 CLI。
Gotchas:`references/gotchas.md` #G06–#G10、#G35–#G36。

---

## Step 2:图片

封面图必须有(≤ 2MB)。生成 → 压缩 → 覆盖原文件(不要改名)。
提示词模板:`references/cover-prompts.md`。
Gotchas:`references/gotchas.md` #G11–#G14。

---

## Step 2.5:Bundle

```bash
Expand All @@ -78,10 +68,17 @@ node ${PIPELINE_HOME}/scripts/bundle_wechat_article.mjs \
⚠️ `.env` 是隐藏文件,必须单独 `scp`。
Gotchas:`references/gotchas.md` #G15–#G17。

---

## Step 3:推送

升级或正式推送前先验 relay 共享脚本;默认只读,漂移即阻断:
```bash
node ${PIPELINE_HOME}/scripts/sync_relay_scripts.mjs --check --json
# 仅在 live mutation 明确获批后:
node ${PIPELINE_HOME}/scripts/sync_relay_scripts.mjs --apply --json
```
`--apply` 会 staged upload → `.bak-<UTC时间>` → promote → hash/语法/参数复检,
失败则按 manifest 回滚。Orchestrator manual/auto 只自动执行 `--check`,绝不 auto-apply。

```bash
ssh relay "cd <dir> && node create_wechat_draft.mjs \
--html article.html --thumb-image cover.png \
Expand All @@ -93,9 +90,7 @@ relay 的 `audit.log` 和 `push-result.json` 带回文章 `publish/vN/`,并在
唯一定位时回写 CATALOG。本机直推只有在确认当前 IP 已进微信白名单时才可用。
证据或 Backlink 不完整时即使微信已收稿也必须报非零。裁剪参数必须从
preflight 输出复制。标题 ≤ 21 中文字,digest ≤ 120 字。
Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33、#G37–#G38。

---
Gotchas:`references/gotchas.md` #G18–#G21、#G31–#G33、#G37–#G39。

## Step 4:回检

Expand All @@ -111,8 +106,6 @@ node ${PIPELINE_HOME}/scripts/reconcile_wechat_drafts.mjs \
对账默认只读且经 relay;草稿消失只标 `published-or-deleted`,封面空值单列。
Gotchas:`references/gotchas.md` #G22–#G24、#G31–#G33。

---

## Step 5:核查(不得跳过)

```bash
Expand All @@ -121,8 +114,5 @@ Skill: "skill-compliance-harness"
输出自查结果:`[回检] img: N✅ / CDN: N✅ / ...`
Gotchas:`references/gotchas.md` #G25–#G26。

---


所有历史踩坑在 `references/gotchas.md`,按 Step 分组。
新增 Gotcha 必须同步更新 `references/gotchas.md` 和 `.spec.md` 第 3 节。
23 changes: 23 additions & 0 deletions harness/test-orchestrator-command-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import os from "node:os";
import path from "node:path";
import {
buildManualRelayCommand,
buildRelayDeploymentCheckCommand,
extractSummaryFromMarkdown,
resolvePipelinePaths,
runRelayDeploymentCheck,
} from "../scripts/orchestrator.mjs";

const result = buildManualRelayCommand({
Expand All @@ -29,9 +31,15 @@ const result = buildManualRelayCommand({
thumbImage: "/tmp/cover.png",
cropSpec: "0_0.0035_1_0.9965",
envInBundle: true,
envPath: "/tmp/md2wechat config/.env",
});

assert.equal(result.envReminder, true);
assert.equal(
result.relayDeploymentCheck,
buildRelayDeploymentCheckCommand({ envPath: "/tmp/md2wechat config/.env" }),
);
assert.match(result.command, /^node .*sync_relay_scripts\.mjs.*--check.*--env.*--json && \\\nssh/);
assert.match(result.command, /ssh 'relay-host'/);
assert.match(result.command, /scp '\/tmp\/wechat bundle'\/\*/);
assert.match(result.command, /scp '\/tmp\/wechat bundle\/\.env'/);
Expand All @@ -47,6 +55,21 @@ assert.match(result.command, /audit\.log/);
assert.match(result.command, /update_wechat_catalog\.mjs/);
assert.doesNotMatch(result.command, /\\scp|\\ssh|\\ node/);

let admissionCall = null;
const blockedAdmission = runRelayDeploymentCheck({
envPath: "/tmp/md2wechat config/.env",
spawn(command, args, options) {
admissionCall = { command, args, options };
return { status: 2, stdout: '{"ok":false,"completion_status":"relay-scripts-drift"}' };
},
});
assert.equal(blockedAdmission.ok, false);
assert.equal(blockedAdmission.exitCode, 2);
assert.match(blockedAdmission.resultPreview, /relay-scripts-drift/);
assert.ok(admissionCall.args.includes("--check"));
assert.ok(admissionCall.args.includes("--json"));
assert.ok(admissionCall.args.includes("/tmp/md2wechat config/.env"));

assert.equal(
extractSummaryFromMarkdown("---\ntitle: Test\nsummary: \"frontmatter 摘要\"\n---\n# H1\n"),
"frontmatter 摘要",
Expand Down
191 changes: 191 additions & 0 deletions harness/test-relay-script-sync-contract.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env node

import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
RELAY_DEPLOYMENT_MANIFEST,
REQUIRED_ENTRY_FLAGS,
buildLocalManifest,
buildRemoteInstallCommand,
buildRemotePreparationCommand,
buildRemoteProbeCommand,
buildRemoteUploadCommand,
evaluateRemoteProbe,
parseCliArgs,
parseRemoteProbeOutput,
} from "../scripts/sync_relay_scripts.mjs";

const harnessDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(harnessDir, "..");

assert.equal(parseCliArgs([]).mode, "check");
assert.equal(parseCliArgs(["--check", "--json"]).json, true);
assert.equal(parseCliArgs(["--apply", "--backup-tag", "proof-tag"]).mode, "apply");
assert.throws(() => parseCliArgs(["--check", "--apply"]), /mutually exclusive/);
assert.throws(() => parseCliArgs(["--apply", "--backup-tag", "unsafe tag"]), /backup-tag/);

const localManifest = buildLocalManifest({ repoRoot });
assert.equal(localManifest.length, RELAY_DEPLOYMENT_MANIFEST.length);
assert.ok(localManifest.every((entry) => /^[a-f0-9]{64}$/.test(entry.sha256)));

const entrySourcePath = path.join(repoRoot, RELAY_DEPLOYMENT_MANIFEST[0].localPath);
const entrySource = fs.readFileSync(entrySourcePath, "utf8");
const localImports = [...entrySource.matchAll(/from\s+["'](\.\/[^"']+)["']/g)].map((match) =>
path.relative(repoRoot, path.resolve(path.dirname(entrySourcePath), match[1])),
);
for (const importedPath of localImports) {
assert.ok(
RELAY_DEPLOYMENT_MANIFEST.some((entry) => entry.localPath === importedPath),
`relay manifest must include local import: ${importedPath}`,
);
}

const probeCommand = buildRemoteProbeCommand({
remoteDir: "/relay/shared/scripts",
manifest: localManifest,
});
assert.match(probeCommand, /create_wechat_draft\.mjs/);
assert.match(probeCommand, /lib\/memory-lib\.mjs/);
for (const flag of REQUIRED_ENTRY_FLAGS) assert.ok(probeCommand.includes(flag));
assert.match(probeCommand, /node --check/);

const alignedOutput = [
...localManifest.flatMap((entry) => [
`FILE\t${entry.remotePath}\t${entry.sha256}`,
`SYNTAX\t${entry.remotePath}\t1`,
]),
...REQUIRED_ENTRY_FLAGS.map((flag) => `FLAG\t${flag}\t1`),
].join("\n");
const aligned = evaluateRemoteProbe({
localManifest,
remoteProbe: parseRemoteProbeOutput(alignedOutput),
});
assert.equal(aligned.ok, true);
assert.ok(aligned.files.every((entry) => entry.status === "aligned"));

const driftedOutput = alignedOutput.replace(localManifest[0].sha256, "0".repeat(64));
const drifted = evaluateRemoteProbe({
localManifest,
remoteProbe: parseRemoteProbeOutput(driftedOutput),
});
assert.equal(drifted.ok, false);
assert.equal(drifted.files[0].status, "drift");

const missingFlagOutput = alignedOutput.replace("FLAG\t--audit-out\t1", "FLAG\t--audit-out\t0");
assert.equal(
evaluateRemoteProbe({
localManifest,
remoteProbe: parseRemoteProbeOutput(missingFlagOutput),
}).ok,
false,
);

const preparation = buildRemotePreparationCommand({
remoteDir: "/relay/shared/scripts",
localManifest,
backupTag: "proof-tag",
});
assert.equal(preparation.stagingPaths.length, localManifest.length);
assert.match(preparation.command, /mkdir -p/);
assert.match(preparation.command, /\.staged-proof-tag/);
assert.equal(
buildRemoteUploadCommand("/relay/shared scripts/writer.staged-proof-tag"),
"cat > '/relay/shared scripts/writer.staged-proof-tag'",
);

const installCommand = buildRemoteInstallCommand({
remoteDir: "/relay/shared/scripts",
localManifest,
backupTag: "proof-tag",
});
assert.match(installCommand, /\.bak-proof-tag/);
assert.match(installCommand, /\.staged-proof-tag/);
assert.match(installCommand, /cp -p/);
assert.match(installCommand, /mv /);
assert.match(installCommand, /rollback_ok/);
assert.match(installCommand, /exit 51/);
assert.match(installCommand, /exit 52/);
for (const entry of localManifest) assert.ok(installCommand.includes(entry.sha256));
for (const flag of REQUIRED_ENTRY_FLAGS) assert.ok(installCommand.includes(flag));

function sha256Text(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}

function writeInstallFixture(root, { tag, entryContent, dependencyContent }) {
const entryTarget = path.join(root, "create_wechat_draft.mjs");
const dependencyTarget = path.join(root, "lib", "memory-lib.mjs");
fs.mkdirSync(path.dirname(dependencyTarget), { recursive: true });
fs.writeFileSync(entryTarget, "console.log('old entry');\n", "utf8");
fs.writeFileSync(dependencyTarget, "export const oldDependency = true;\n", "utf8");
fs.writeFileSync(`${entryTarget}.staged-${tag}`, entryContent, "utf8");
fs.writeFileSync(`${dependencyTarget}.staged-${tag}`, dependencyContent, "utf8");
return {
manifest: [
{
localPath: "scripts/create_wechat_draft.mjs",
remotePath: "create_wechat_draft.mjs",
sha256: sha256Text(entryContent),
},
{
localPath: "scripts/lib/memory-lib.mjs",
remotePath: "lib/memory-lib.mjs",
sha256: sha256Text(dependencyContent),
},
],
entryTarget,
dependencyTarget,
};
}

const installTmp = fs.mkdtempSync(path.join(os.tmpdir(), "md2wechat-relay-install-"));
try {
const requiredFlag = "--required-flag";
const successTag = "success-tag";
const successEntry = `if (process.argv.includes("--help")) console.log("${requiredFlag}");\n`;
const successDependency = "export const deployedDependency = true;\n";
const success = writeInstallFixture(installTmp, {
tag: successTag,
entryContent: successEntry,
dependencyContent: successDependency,
});
const successCommand = buildRemoteInstallCommand({
remoteDir: installTmp,
localManifest: success.manifest,
backupTag: successTag,
requiredFlags: [requiredFlag],
});
const successRun = spawnSync("sh", ["-c", successCommand], { encoding: "utf8" });
assert.equal(successRun.status, 0, successRun.stderr);
assert.equal(fs.readFileSync(success.entryTarget, "utf8"), successEntry);
assert.equal(fs.readFileSync(success.dependencyTarget, "utf8"), successDependency);
assert.equal(fs.readFileSync(`${success.entryTarget}.bak-${successTag}`, "utf8"), "console.log('old entry');\n");

const rollbackRoot = path.join(installTmp, "rollback fixture");
const rollbackTag = "rollback-tag";
const badEntry = "console.log('help without required flag');\n";
const rollback = writeInstallFixture(rollbackRoot, {
tag: rollbackTag,
entryContent: badEntry,
dependencyContent: successDependency,
});
const rollbackCommand = buildRemoteInstallCommand({
remoteDir: rollbackRoot,
localManifest: rollback.manifest,
backupTag: rollbackTag,
requiredFlags: [requiredFlag],
});
const rollbackRun = spawnSync("sh", ["-c", rollbackCommand], { encoding: "utf8" });
assert.equal(rollbackRun.status, 51, rollbackRun.stderr);
assert.equal(fs.readFileSync(rollback.entryTarget, "utf8"), "console.log('old entry');\n");
assert.equal(fs.readFileSync(rollback.dependencyTarget, "utf8"), "export const oldDependency = true;\n");
} finally {
fs.rmSync(installTmp, { recursive: true, force: true });
}

console.log("Relay script sync contract passed.");
2 changes: 2 additions & 0 deletions md2wechat.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ md2wechat/
- [ ] bundle 目录是 single source of truth,推送时只传这个目录里的文件

### Step 3(推送)
- [ ] 正式 relay 推送前必须运行共享脚本 manifest `--check`;漂移时阻断,不能边推文章边 auto-apply
- [ ] relay 部署必须显式 `--apply`,并具备 staging、逐文件备份、hash/语法/参数复检和失败回滚
- [ ] 裁剪参数必须从 preflight 输出中复制,不能猜测
- [ ] 标题 ≤ 21 个中文字(微信 64 字节限制)
- [ ] digest(summary)≤ 120 字(微信 digest 限制)
Expand Down
Loading
Loading