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
29 changes: 29 additions & 0 deletions .github/release-notes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Reviewed OpenClaw cloud plugin Release Notes

This directory is optional. A version-bump pull request may add
`.github/release-notes/v<version>.md` when release owners want to provide the
reviewed GitHub Release body themselves. For example:

```text
.github/release-notes/v0.1.21.md
```

The version in the filename must match all four committed version files. No
special branch name is required. The file must use the same evidence-backed
contract as the former `release_notes` workflow input:

- public `## Changelog` Markdown;
- one hidden `doc-agent-release-notes-json` block containing bilingual items
and real `source_refs`;
- one exact `<!-- doc-agent: source-id=openclaw-cloud-plugin -->` marker.

The release workflow validates the file. Invalid manual notes fail closed and
cannot publish npm, a tag, or a Draft Release.

The automatic workflow reads this file directly from the immutable merged
commit. It does not copy the Markdown through cross-job outputs and does not
create a version-update pull request.

When this file is absent, the workflow collects immutable git evidence and asks
the configured 106 Doc Agent draft endpoint to generate and repair the same
contract. Do not commit internal URLs, tokens, prompts, or unredacted logs here.
25 changes: 24 additions & 1 deletion .github/scripts/draft-cloud-plugin-release-notes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1972,7 +1972,30 @@ export async function main() {
const docsPreviewMarkdownPath = join(tmpdir(), `openclaw-cloud-plugin-${targetVersion}-docs-preview.md`);
const qualityReportPath = join(tmpdir(), `openclaw-cloud-plugin-${targetVersion}-quality-report.json`);

const suppliedManualNotes = String(process.env.MANUAL_RELEASE_NOTES || "").trim();
const suppliedManualNotesFile = String(
process.env.MANUAL_RELEASE_NOTES_FILE || "",
).trim();
const suppliedManualNotesInput = String(
process.env.MANUAL_RELEASE_NOTES || "",
).trim();
if (suppliedManualNotesFile && suppliedManualNotesInput) {
fail(
"Provide reviewed Release Notes through either MANUAL_RELEASE_NOTES_FILE or MANUAL_RELEASE_NOTES, not both.",
);
}
let suppliedManualNotes = suppliedManualNotesInput;
if (suppliedManualNotesFile) {
try {
suppliedManualNotes = readFileSync(suppliedManualNotesFile, "utf8").trim();
} catch (error) {
fail(
`Cannot read reviewed Release Notes file ${suppliedManualNotesFile}: ${cleanError(error?.message || error)}`,
);
}
if (!suppliedManualNotes) {
fail(`Reviewed Release Notes file ${suppliedManualNotesFile} is empty.`);
}
}
const manualNotes =
faultCase === "manual_notes_missing_payload"
? "## Changelog\n\n### Added\n- Intentionally missing the hidden evidence payload."
Expand Down
16 changes: 9 additions & 7 deletions .github/scripts/draft-cloud-plugin-release-notes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1180,20 +1180,22 @@ test("manual release notes also produce docs preview outputs", async () => {
try {
const outputPath = join(directory, "github-output.txt");
const notesPath = join(directory, "release-notes.md");
const reviewedNotesPath = join(directory, "v0.1.19.md");
const sourceRef = runGit(process.cwd(), ["rev-parse", "--short", "d053b0a"]);
Object.assign(process.env, {
RELEASE_VERSION: "0.1.19",
RELEASE_TAG: "v0.1.19",
RELEASE_EVIDENCE_REF: "v0.1.19",
RELEASE_NOTES_FILE: notesPath,
MANUAL_RELEASE_NOTES: `## Changelog
writeFileSync(reviewedNotesPath, `## Changelog

### Improved
- **系统提示识别优化**:兼容单行压缩内容并降低普通消息误判概率。

<!-- doc-agent-release-notes-json
{"items":[{"category":"Improved","text_cn":"**系统提示识别优化**:兼容单行压缩内容并降低普通消息误判概率。","text_en":"**System prompt detection**: Supports flattened single-line prompts and reduces false positives on regular messages.","source_refs":["${sourceRef}"]}],"coverage":{"needs_review":false,"required_count":1,"covered_required_count":1,"missing_required_count":0}}
-->`,
-->`, "utf8");
Object.assign(process.env, {
RELEASE_VERSION: "0.1.19",
RELEASE_TAG: "v0.1.19",
RELEASE_EVIDENCE_REF: "v0.1.19",
RELEASE_NOTES_FILE: notesPath,
MANUAL_RELEASE_NOTES_FILE: reviewedNotesPath,
GITHUB_OUTPUT: outputPath,
});

Expand Down
281 changes: 281 additions & 0 deletions .github/scripts/resolve-auto-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
#!/usr/bin/env node

import { execFileSync } from "node:child_process";
import { appendFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { compareSemver, parseSemver } from "../../lib/semver.js";

export const VERSION_FILES = [
"package.json",
"openclaw.plugin.json",
"moltbot.plugin.json",
"clawdbot.plugin.json",
];

function clean(value) {
return String(value ?? "").trim();
}

function exactSha(value) {
return /^[0-9a-fA-F]{40}$/.test(clean(value));
}

export function npmDistTagForVersion(version) {
const parsed = parseSemver(version);
if (!parsed) throw new Error(`version must be valid SemVer; got '${clean(version)}'`);
if (parsed.prerelease.length === 0) return "latest";
const identifier = String(parsed.prerelease[0] || "").toLowerCase();
return ["alpha", "beta", "next"].includes(identifier) ? identifier : "next";
}

export function versionsFromFiles(root = process.cwd()) {
return Object.fromEntries(
VERSION_FILES.map((file) => {
const payload = JSON.parse(readFileSync(join(root, file), "utf8"));
return [file, clean(payload.version)];
}),
);
}

export function versionsFromGitRef(ref, root = process.cwd()) {
const sourceRef = clean(ref);
if (!sourceRef) throw new Error("a git ref is required to read previous versions");
return Object.fromEntries(
VERSION_FILES.map((file) => {
const raw = execFileSync("git", ["show", `${sourceRef}:${file}`], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return [file, clean(JSON.parse(raw).version)];
}),
);
}

function changedVersionFiles(previousVersions, currentVersions) {
return VERSION_FILES.filter(
(file) => clean(previousVersions?.[file]) !== clean(currentVersions?.[file]),
);
}

function commonVersion(versions, label) {
const values = VERSION_FILES.map((file) => clean(versions?.[file]));
const missing = VERSION_FILES.filter((_, index) => !values[index]);
if (missing.length > 0) {
return {
ok: false,
reason: `${label} is missing version values in ${missing.join(", ")}`,
};
}
const unique = [...new Set(values)];
if (unique.length !== 1) {
return {
ok: false,
reason: `${label} has inconsistent versions: ${VERSION_FILES.map((file) => `${file}=${clean(versions?.[file]) || "missing"}`).join(", ")}`,
};
}
return { ok: true, version: unique[0] };
}

export function inspectVersionTransition({ previousVersions, currentVersions }) {
const changedFiles = changedVersionFiles(previousVersions, currentVersions);
if (changedFiles.length === 0) {
return {
ok: true,
eligible: false,
reason: "the merge did not change any release version value",
changed_version_files: [],
};
}

const previous = commonVersion(previousVersions, "the pre-merge main source");
if (!previous.ok) return { ...previous, eligible: false, changed_version_files: changedFiles };
if (changedFiles.length !== VERSION_FILES.length) {
const unchanged = VERSION_FILES.filter((file) => !changedFiles.includes(file));
return {
ok: false,
eligible: false,
reason: `a release version changed, but all four version files were not updated; unchanged: ${unchanged.join(", ")}`,
previous_version: previous.version,
version: "",
changed_version_files: changedFiles,
};
}
const current = commonVersion(currentVersions, "the merged main source");
if (!current.ok) return { ...current, eligible: false, changed_version_files: changedFiles };
if (!parseSemver(previous.version) || !parseSemver(current.version)) {
return {
ok: false,
eligible: false,
reason: `release versions must be strict SemVer; got ${previous.version} -> ${current.version}`,
previous_version: previous.version,
version: current.version,
changed_version_files: changedFiles,
};
}
if (previous.version.includes("+") || current.version.includes("+")) {
return {
ok: false,
eligible: false,
reason: "automatic releases reject SemVer build metadata because npm precedence and immutable v-tags would become ambiguous",
previous_version: previous.version,
version: current.version,
changed_version_files: changedFiles,
};
}
if (compareSemver(current.version, previous.version) <= 0) {
return {
ok: false,
eligible: false,
reason: `release version must increase by SemVer precedence; got ${previous.version} -> ${current.version}`,
previous_version: previous.version,
version: current.version,
changed_version_files: changedFiles,
};
}

return {
ok: true,
eligible: true,
reason: `all four committed versions increased from ${previous.version} to ${current.version}`,
previous_version: previous.version,
version: current.version,
npm_dist_tag: npmDistTagForVersion(current.version),
github_release_prerelease: parseSemver(current.version).prerelease.length > 0,
release_notes_path: `.github/release-notes/v${current.version}.md`,
changed_version_files: changedFiles,
};
}

export function inspectPullRequestEvent({
eventName,
merged,
baseRef,
headRepo,
repository,
mergeSha,
baseSha,
}) {
if (eventName !== "pull_request") {
return { ok: true, inspect: false, reason: "manual workflow dispatch" };
}
if (clean(merged).toLowerCase() !== "true") {
return { ok: true, inspect: false, reason: "pull request closed without merge" };
}
if (baseRef !== "main") {
return { ok: true, inspect: false, reason: `pull request targets ${baseRef || "an unknown branch"}, not main` };
}
if (headRepo !== repository) {
return { ok: true, inspect: false, reason: "fork pull requests cannot authorize an automatic release" };
}
if (!exactSha(mergeSha) || !exactSha(baseSha)) {
return {
ok: false,
inspect: false,
reason: "merged PR must provide exact 40-character base and merge commit SHAs",
};
}
return { ok: true, inspect: true, reason: "same-repository PR merged into main" };
}

export function validateAutoRelease({
eventName,
merged,
baseRef,
headRepo,
repository,
mergeSha,
baseSha,
previousVersions,
currentVersions,
}) {
const event = inspectPullRequestEvent({
eventName,
merged,
baseRef,
headRepo,
repository,
mergeSha,
baseSha,
});
if (!event.ok || !event.inspect) return { ...event, eligible: false };
const transition = inspectVersionTransition({ previousVersions, currentVersions });
return {
...transition,
inspect: true,
target_sha: clean(mergeSha),
base_sha: clean(baseSha),
};
}

function writeOutputs(result, outputFile) {
if (!outputFile) return;
appendFileSync(
outputFile,
[
`eligible=${result.eligible === true}`,
`reason=${clean(result.reason).replaceAll("\n", " ")}`,
`previous_version=${result.previous_version || ""}`,
`version=${result.version || ""}`,
`npm_dist_tag=${result.npm_dist_tag || ""}`,
`github_release_prerelease=${result.github_release_prerelease === true}`,
`target_sha=${result.target_sha || ""}`,
`base_sha=${result.base_sha || ""}`,
`release_notes_path=${result.release_notes_path || ""}`,
"",
].join("\n"),
"utf8",
);
}

export function main(env = process.env) {
const event = inspectPullRequestEvent({
eventName: env.EVENT_NAME,
merged: env.PR_MERGED,
baseRef: env.PR_BASE_REF,
headRepo: env.PR_HEAD_REPO,
repository: env.GITHUB_REPOSITORY,
mergeSha: env.PR_MERGE_SHA,
baseSha: env.PR_BASE_SHA,
});
if (!event.ok || !event.inspect) {
const result = { ...event, eligible: false };
writeOutputs(result, env.GITHUB_OUTPUT);
if (!result.ok) throw new Error(result.reason);
console.log(`Automatic release skipped: ${result.reason}`);
return result;
}
const root = env.GITHUB_WORKSPACE || process.cwd();
const currentVersions = versionsFromGitRef(env.PR_MERGE_SHA, root);
const previousVersions = versionsFromGitRef(env.PR_BASE_SHA, root);
const result = validateAutoRelease({
eventName: env.EVENT_NAME,
merged: env.PR_MERGED,
baseRef: env.PR_BASE_REF,
headRepo: env.PR_HEAD_REPO,
repository: env.GITHUB_REPOSITORY,
mergeSha: env.PR_MERGE_SHA,
baseSha: env.PR_BASE_SHA,
previousVersions,
currentVersions,
});
writeOutputs(result, env.GITHUB_OUTPUT);
if (!result.ok) throw new Error(result.reason);
console.log(
result.eligible
? `Automatic release accepted: ${result.previous_version} -> ${result.version}, npm tag=${result.npm_dist_tag}, target=${result.target_sha}`
: `Automatic release skipped: ${result.reason}`,
);
return result;
}

const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
try {
main();
} catch (error) {
console.error(`::error::${error?.message || String(error)}`);
process.exitCode = 1;
}
}
Loading