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
119 changes: 113 additions & 6 deletions .github/scripts/draft-local-plugin-release-notes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const CURRENT_TAG_PREFIX = "memos-local-plugin-v";
const TAG_PREFIXES = [CURRENT_TAG_PREFIX, "openclaw-local-plugin-v"];
const RELEASE_NOTES_MARKER = "doc-agent-release-notes-json";
const RELEASE_CATEGORY_ORDER = ["Added", "Improved", "Fixed"];
const MAX_DRAFT_REPAIR_ATTEMPTS = 2;
const MAX_DRAFT_REPAIR_ATTEMPTS = 3;
const RELEASE_TO_DOC_CATEGORY = {
Added: "New Features",
Improved: "Improvements",
Expand Down Expand Up @@ -72,6 +72,12 @@ export function displayVersion(raw) {
return value ? `v${value}` : "";
}

export function isLegacyPackageOnlyRelease({ targetVersion, npmDistTag = "" } = {}) {
const parsed = parseSemver(targetVersion);
const distTag = String(npmDistTag || "").trim();
return Boolean(parsed?.prerelease) || Boolean(distTag && distTag !== "latest");
}

export function versionFromTag(tag) {
for (const prefix of TAG_PREFIXES) {
if (tag.startsWith(prefix)) {
Expand All @@ -83,7 +89,7 @@ export function versionFromTag(tag) {

export function parseSemver(version) {
const cleaned = cleanVersion(version);
const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+]([0-9A-Za-z.-]+))?$/);
const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
if (!match) return null;
return {
major: Number(match[1]),
Expand Down Expand Up @@ -173,10 +179,16 @@ function listProductTags() {
.filter((item) => item.version && parseSemver(item.version));
}

export function findPreviousTag(targetVersion, currentTag) {
const candidates = listProductTags()
export function selectPreviousTag(
tags,
targetVersion,
currentTag,
{ includePrerelease = true } = {},
) {
const candidates = tags
.filter((item) => item.tag !== currentTag)
.filter((item) => compareSemver(item.version, targetVersion) < 0)
.filter((item) => includePrerelease || !parseSemver(item.version)?.prerelease)
.sort((a, b) => {
const versionOrder = compareSemver(b.version, a.version);
if (versionOrder !== 0) return versionOrder;
Expand All @@ -187,6 +199,10 @@ export function findPreviousTag(targetVersion, currentTag) {
return candidates[0]?.tag || "";
}

export function findPreviousTag(targetVersion, currentTag, options = {}) {
return selectPreviousTag(listProductTags(), targetVersion, currentTag, options);
}

function parseCommits(previousTag, currentRef) {
const text = sh([
"log",
Expand Down Expand Up @@ -428,6 +444,17 @@ export function ensureSourceHint(notes) {
return notes.includes("doc-agent: source-id=") ? notes : `${notes.trim()}\n\n${hint}\n`;
}

export function validateLegacyPackageNotes(notes) {
const text = String(notes || "").trim();
if (!/^## Changelog\s*$/m.test(text)) {
fail("Legacy package release notes must contain a '## Changelog' heading.");
}
if (/doc-agent:\s*source-id=|doc-agent-release-notes-json/.test(text)) {
fail("Legacy package release notes must not include Doc Agent source hints or docs payloads.");
}
return `${text}\n`;
}

function normalizeReleaseCategory(value) {
const text = String(value || "").trim();
return RELEASE_CATEGORY_ORDER.includes(text) ? text : "";
Expand Down Expand Up @@ -1007,6 +1034,54 @@ function markdownFromReleaseItems(items, coverage) {
return `${lines.join("\n").trim()}\n`;
}

export function legacyPackageDraftFromEvidence(evidence, { npmDistTag = "" } = {}) {
const version = evidence?.target_version || "";
const targetPackageVersion = cleanVersion(version);
const gitRef = evidence?.git_ref || "";
const previousTag = evidence?.previous_tag || "";
const currentTag = evidence?.current_tag || "";
const distTag = String(npmDistTag || "").trim() || "beta";
const changedFileCount = Array.isArray(evidence?.changed_files) ? evidence.changed_files.length : 0;
const commitCount = Array.isArray(evidence?.commits) ? evidence.commits.length : 0;
const packageChanges = Array.isArray(evidence?.package_changes) ? evidence.package_changes : [];
const versionChange = packageChanges.find((item) => item.field === "version");
const lines = [
"## Changelog",
"",
"### Prerelease",
`- Published ${PRODUCT_TITLE.en} ${version} as a package prerelease for validation through the npm \`${distTag}\` dist-tag.`,
"",
"### Release Evidence",
`- Package tag: ${currentTag}`,
`- Previous package tag: ${previousTag}`,
`- Source commit: ${gitRef}`,
`- Local plugin commits: ${commitCount}`,
`- Local plugin changed files: ${changedFileCount}`,
];
const previousPackageVersion = versionChange?.before || "unknown";
lines.push(`- Package version: ${previousPackageVersion} -> ${targetPackageVersion}`);
lines.push("");
lines.push("This legacy prerelease is package-only and does not update the MemOS-Docs Plugin tab.");
return {
ok: true,
needs_review: false,
confidence: "legacy-package-only",
release_items: [],
coverage: {
needs_review: false,
required_count: 0,
covered_required_count: 0,
missing_required_count: 0,
covered_refs: [],
missing_required: [],
invalid_item_refs: [],
policy: "legacy local-plugin prereleases are package-only and do not create docs payloads",
},
warnings: ["legacy package-only prerelease skipped Doc Agent draft and docs payload generation"],
release_notes_markdown: `${lines.join("\n").trim()}\n`,
};
}

export function postprocessDraftFromEvidence(draft, evidence) {
const inputItems = Array.isArray(draft?.release_items)
? draft.release_items.map(normalizeReleaseItem).filter(Boolean)
Expand Down Expand Up @@ -1290,21 +1365,28 @@ export async function main() {
if (!targetVersion) fail("RELEASE_VERSION is required.");

const currentTag = process.env.RELEASE_TAG || `${CURRENT_TAG_PREFIX}${targetVersion}`;
const npmDistTag = String(process.env.NPM_DIST_TAG || "").trim();
const legacyPackageOnly = isLegacyPackageOnlyRelease({ targetVersion, npmDistTag });
const notesPath =
process.env.RELEASE_NOTES_FILE ||
join(tmpdir(), `memos-local-plugin-${targetVersion}-release-notes.md`);
mkdirSync(dirname(notesPath), { recursive: true });

const manualNotes = String(process.env.MANUAL_RELEASE_NOTES || "").trim();
if (manualNotes) {
writeFileSync(notesPath, ensureSourceHint(validateManualNotes(manualNotes)), "utf8");
const notes = legacyPackageOnly
? validateLegacyPackageNotes(manualNotes)
: ensureSourceHint(validateManualNotes(manualNotes));
writeFileSync(notesPath, notes, "utf8");
appendOutput("release_notes_file", notesPath);
appendOutput("draft_used", "false");
console.log(`Using manually provided release notes: ${notesPath}`);
return;
}

const previousTag = findPreviousTag(targetVersion, currentTag);
const previousTag = findPreviousTag(targetVersion, currentTag, {
includePrerelease: legacyPackageOnly,
});
if (!previousTag) {
fail(`Cannot find a previous local plugin tag before ${currentTag}.`);
}
Expand All @@ -1314,6 +1396,31 @@ export async function main() {
const evidencePath = join(tmpdir(), `memos-local-plugin-${targetVersion}-evidence.json`);
writeFileSync(evidencePath, JSON.stringify(evidenceForInspection(evidence), null, 2), "utf8");

if (legacyPackageOnly) {
const draft = legacyPackageDraftFromEvidence(evidence, { npmDistTag });
const draftPath = join(tmpdir(), `memos-local-plugin-${targetVersion}-release-notes-draft.json`);
writeFileSync(draftPath, JSON.stringify(draftForInspection(draft), null, 2), "utf8");
writeFileSync(notesPath, draft.release_notes_markdown, "utf8");

appendOutput("release_notes_file", notesPath);
appendOutput("evidence_file", evidencePath);
appendOutput("draft_file", draftPath);
appendOutput("draft_used", "false");
appendOutput("previous_tag", previousTag);
appendOutput("current_tag", currentTag);
appendOutput("current_ref", currentRef);
appendOutput("draft_confidence", draft.confidence);
appendOutput("missing_required_count", "0");
appendOutput("validation_attempt_count", "0");
appendOutput("repair_attempt_count", "0");

console.log(`Generated package-only prerelease notes without Doc Agent: ${notesPath}`);
console.log(`Previous tag: ${previousTag}`);
console.log(`Current tag: ${currentTag}`);
console.log(`Current evidence ref: ${currentRef}`);
return;
}

const draft = await requestValidatedDraft(evidence);
if (!draft.ok || draft.needs_review) {
fail(`Postprocessed release notes require review: ${JSON.stringify(draft.validation_report || draft.coverage || {})}`);
Expand Down
149 changes: 149 additions & 0 deletions .github/scripts/draft-local-plugin-release-notes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import {
draftForInspection,
evidenceForInspection,
ensureSourceHint,
isLegacyPackageOnlyRelease,
legacyPackageDraftFromEvidence,
parseSemver,
RELEASE_NOTE_GUIDANCE,
postprocessDraftFromEvidence,
reportExternalFailureFromEnv,
requestDraft,
requestValidatedDraft,
resolveCurrentRef,
selectPreviousTag,
validateLegacyPackageNotes,
validateManualNotes,
versionFromTag,
} from "./draft-local-plugin-release-notes.mjs";
Expand Down Expand Up @@ -46,6 +51,70 @@ test("normalizes only real local-plugin tag families", () => {
assert.equal(versionFromTag("v2.0.10"), "");
});

test("detects legacy package-only prereleases", () => {
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13-beta.1", npmDistTag: "beta" }), true);
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13-beta.1", npmDistTag: "latest" }), true);
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "beta" }), true);
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "next" }), true);
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "latest" }), false);
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "" }), false);
});

test("treats SemVer build metadata as stable metadata, not a prerelease channel", () => {
assert.equal(parseSemver("2.0.13+build.7").prerelease, "");
assert.equal(parseSemver("2.0.13-beta.1+build.7").prerelease, "beta.1");
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13+build.7", npmDistTag: "latest" }), false);
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13-beta.1+build.7", npmDistTag: "latest" }), true);
});

test("uses the previous stable tag for docs-generating latest releases", () => {
const tags = [
{ tag: "memos-local-plugin-v2.0.12", version: "2.0.12" },
{ tag: "memos-local-plugin-v2.0.13-beta.1", version: "2.0.13-beta.1" },
{ tag: "memos-local-plugin-v2.0.13", version: "2.0.13" },
];

assert.equal(
selectPreviousTag(tags, "2.0.13", "memos-local-plugin-v2.0.13", { includePrerelease: false }),
"memos-local-plugin-v2.0.12",
);
assert.equal(
selectPreviousTag(tags, "2.0.13", "memos-local-plugin-v2.0.13", { includePrerelease: true }),
"memos-local-plugin-v2.0.13-beta.1",
);
});

test("generates package-only prerelease notes without docs payloads", () => {
const draft = legacyPackageDraftFromEvidence(
{
current_tag: "memos-local-plugin-v2.0.13-beta.1",
previous_tag: "memos-local-plugin-v2.0.12",
target_version: "v2.0.13-beta.1",
git_ref: "abc1234",
changed_files: [{ status: "M", path: "apps/memos-local-plugin/package.json" }],
commits: [
{
sha: "abc1234000000000000000000000000000000000",
short_sha: "abc1234",
subject: "test: prepare local plugin beta package",
},
],
package_changes: [{ field: "version", before: "2.0.12", after: "2.0.13-beta.1" }],
},
{ npmDistTag: "beta" },
);

assert.equal(draft.ok, true);
assert.equal(draft.needs_review, false);
assert.equal(draft.confidence, "legacy-package-only");
assert.equal(draft.coverage.missing_required_count, 0);
assert.match(draft.release_notes_markdown, /## Changelog/);
assert.match(draft.release_notes_markdown, /npm `beta` dist-tag/);
assert.match(draft.release_notes_markdown, /package-only/);
assert.doesNotMatch(draft.release_notes_markdown, /doc-agent: source-id=/);
assert.doesNotMatch(draft.release_notes_markdown, /doc-agent-release-notes-json/);
});

test("uses an existing release tag as the evidence endpoint", () => {
const exists = (ref) => ref === "memos-local-plugin-v2.0.10" || ref === "manual-ref";
assert.equal(
Expand Down Expand Up @@ -458,6 +527,69 @@ test("repairs postprocessed language validation issues with exact context", asyn
assert.match(result.release_notes_markdown, /doc-agent-release-notes-json/);
});

test("standalone latest draft validation allows one initial response plus three repairs by default", async () => {
const repairEvidence = {
commits: [
{
sha: "abc12340000000000000000000000000000000",
short_sha: "abc1234",
subject: "feat: add plugin health dashboard (#3001)",
},
],
release_note_guidance: {
source_ref_category_hints: [
{
category: "Added",
source_refs: ["abc1234", "#3001"],
subject: "feat: add plugin health dashboard (#3001)",
},
],
},
};
const badDraft = {
ok: true,
needs_review: false,
release_items: [
{
category: "Added",
text_cn: "Plugin health dashboard",
text_en: "插件健康看板",
source_refs: ["abc1234", "#3001"],
},
],
coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 },
warnings: [],
};
const goodDraft = {
ok: true,
needs_review: false,
release_items: [
{
category: "Added",
text_cn: "**插件健康看板**:新增本地插件健康状态展示。",
text_en: "**Plugin Health Dashboard**: Added local plugin health status visibility.",
source_refs: ["abc1234", "#3001"],
},
],
coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 },
warnings: [],
};
let calls = 0;
const requestImpl = async () => {
calls += 1;
return calls < 4 ? badDraft : goodDraft;
};

const result = await requestValidatedDraft(repairEvidence, { requestImpl });

assert.equal(calls, 4);
assert.equal(result.ok, true);
assert.equal(result.needs_review, false);
assert.equal(result.validation_attempt_count, 4);
assert.equal(result.repair_attempt_count, 3);
assert.match(result.release_notes_markdown, /插件健康看板/);
});

test("stops release-note repair after two validation repair attempts", async () => {
const repairEvidence = {
commits: [
Expand Down Expand Up @@ -560,6 +692,23 @@ test("manual notes require bilingual evidence refs and passed coverage", () => {
);
});

test("legacy package manual notes reject docs payloads", () => {
const valid = `## Changelog

### Prerelease
- Published MemOS Local Plugin v2.0.13-beta.1 as a beta package.`;
assert.equal(validateLegacyPackageNotes(valid), `${valid}\n`);
assert.throws(
() => validateLegacyPackageNotes(`${valid}\n\n<!-- doc-agent: source-id=openclaw-local-plugin -->`),
/must not include Doc Agent source hints/,
);
assert.throws(
() => validateLegacyPackageNotes(`${valid}\n\n<!-- doc-agent-release-notes-json\n{}\n-->`),
/must not include Doc Agent source hints/,
);
assert.throws(() => validateLegacyPackageNotes("### Prerelease\n- missing changelog"), /Changelog/);
});

test("retries transient draft failures and passes prior error context", async () => {
const previous = { ...process.env };
try {
Expand Down
Loading
Loading