Skip to content

Commit 93e4082

Browse files
ci: keep local plugin standalone publishing compatible with MemOS releases (#2198)
* ci: keep local plugin beta releases package-only * ci: harden local plugin docs fallback filtering * ci: align local plugin standalone quality gates --------- Co-authored-by: MLittleprince <MLittleprince@users.noreply.github.com>
1 parent 9e2654f commit 93e4082

5 files changed

Lines changed: 357 additions & 14 deletions

.github/scripts/draft-local-plugin-release-notes.mjs

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const CURRENT_TAG_PREFIX = "memos-local-plugin-v";
3737
const TAG_PREFIXES = [CURRENT_TAG_PREFIX, "openclaw-local-plugin-v"];
3838
const RELEASE_NOTES_MARKER = "doc-agent-release-notes-json";
3939
const RELEASE_CATEGORY_ORDER = ["Added", "Improved", "Fixed"];
40-
const MAX_DRAFT_REPAIR_ATTEMPTS = 2;
40+
const MAX_DRAFT_REPAIR_ATTEMPTS = 3;
4141
const RELEASE_TO_DOC_CATEGORY = {
4242
Added: "New Features",
4343
Improved: "Improvements",
@@ -72,6 +72,12 @@ export function displayVersion(raw) {
7272
return value ? `v${value}` : "";
7373
}
7474

75+
export function isLegacyPackageOnlyRelease({ targetVersion, npmDistTag = "" } = {}) {
76+
const parsed = parseSemver(targetVersion);
77+
const distTag = String(npmDistTag || "").trim();
78+
return Boolean(parsed?.prerelease) || Boolean(distTag && distTag !== "latest");
79+
}
80+
7581
export function versionFromTag(tag) {
7682
for (const prefix of TAG_PREFIXES) {
7783
if (tag.startsWith(prefix)) {
@@ -83,7 +89,7 @@ export function versionFromTag(tag) {
8389

8490
export function parseSemver(version) {
8591
const cleaned = cleanVersion(version);
86-
const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+]([0-9A-Za-z.-]+))?$/);
92+
const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
8793
if (!match) return null;
8894
return {
8995
major: Number(match[1]),
@@ -173,10 +179,16 @@ function listProductTags() {
173179
.filter((item) => item.version && parseSemver(item.version));
174180
}
175181

176-
export function findPreviousTag(targetVersion, currentTag) {
177-
const candidates = listProductTags()
182+
export function selectPreviousTag(
183+
tags,
184+
targetVersion,
185+
currentTag,
186+
{ includePrerelease = true } = {},
187+
) {
188+
const candidates = tags
178189
.filter((item) => item.tag !== currentTag)
179190
.filter((item) => compareSemver(item.version, targetVersion) < 0)
191+
.filter((item) => includePrerelease || !parseSemver(item.version)?.prerelease)
180192
.sort((a, b) => {
181193
const versionOrder = compareSemver(b.version, a.version);
182194
if (versionOrder !== 0) return versionOrder;
@@ -187,6 +199,10 @@ export function findPreviousTag(targetVersion, currentTag) {
187199
return candidates[0]?.tag || "";
188200
}
189201

202+
export function findPreviousTag(targetVersion, currentTag, options = {}) {
203+
return selectPreviousTag(listProductTags(), targetVersion, currentTag, options);
204+
}
205+
190206
function parseCommits(previousTag, currentRef) {
191207
const text = sh([
192208
"log",
@@ -428,6 +444,17 @@ export function ensureSourceHint(notes) {
428444
return notes.includes("doc-agent: source-id=") ? notes : `${notes.trim()}\n\n${hint}\n`;
429445
}
430446

447+
export function validateLegacyPackageNotes(notes) {
448+
const text = String(notes || "").trim();
449+
if (!/^## Changelog\s*$/m.test(text)) {
450+
fail("Legacy package release notes must contain a '## Changelog' heading.");
451+
}
452+
if (/doc-agent:\s*source-id=|doc-agent-release-notes-json/.test(text)) {
453+
fail("Legacy package release notes must not include Doc Agent source hints or docs payloads.");
454+
}
455+
return `${text}\n`;
456+
}
457+
431458
function normalizeReleaseCategory(value) {
432459
const text = String(value || "").trim();
433460
return RELEASE_CATEGORY_ORDER.includes(text) ? text : "";
@@ -1007,6 +1034,54 @@ function markdownFromReleaseItems(items, coverage) {
10071034
return `${lines.join("\n").trim()}\n`;
10081035
}
10091036

1037+
export function legacyPackageDraftFromEvidence(evidence, { npmDistTag = "" } = {}) {
1038+
const version = evidence?.target_version || "";
1039+
const targetPackageVersion = cleanVersion(version);
1040+
const gitRef = evidence?.git_ref || "";
1041+
const previousTag = evidence?.previous_tag || "";
1042+
const currentTag = evidence?.current_tag || "";
1043+
const distTag = String(npmDistTag || "").trim() || "beta";
1044+
const changedFileCount = Array.isArray(evidence?.changed_files) ? evidence.changed_files.length : 0;
1045+
const commitCount = Array.isArray(evidence?.commits) ? evidence.commits.length : 0;
1046+
const packageChanges = Array.isArray(evidence?.package_changes) ? evidence.package_changes : [];
1047+
const versionChange = packageChanges.find((item) => item.field === "version");
1048+
const lines = [
1049+
"## Changelog",
1050+
"",
1051+
"### Prerelease",
1052+
`- Published ${PRODUCT_TITLE.en} ${version} as a package prerelease for validation through the npm \`${distTag}\` dist-tag.`,
1053+
"",
1054+
"### Release Evidence",
1055+
`- Package tag: ${currentTag}`,
1056+
`- Previous package tag: ${previousTag}`,
1057+
`- Source commit: ${gitRef}`,
1058+
`- Local plugin commits: ${commitCount}`,
1059+
`- Local plugin changed files: ${changedFileCount}`,
1060+
];
1061+
const previousPackageVersion = versionChange?.before || "unknown";
1062+
lines.push(`- Package version: ${previousPackageVersion} -> ${targetPackageVersion}`);
1063+
lines.push("");
1064+
lines.push("This legacy prerelease is package-only and does not update the MemOS-Docs Plugin tab.");
1065+
return {
1066+
ok: true,
1067+
needs_review: false,
1068+
confidence: "legacy-package-only",
1069+
release_items: [],
1070+
coverage: {
1071+
needs_review: false,
1072+
required_count: 0,
1073+
covered_required_count: 0,
1074+
missing_required_count: 0,
1075+
covered_refs: [],
1076+
missing_required: [],
1077+
invalid_item_refs: [],
1078+
policy: "legacy local-plugin prereleases are package-only and do not create docs payloads",
1079+
},
1080+
warnings: ["legacy package-only prerelease skipped Doc Agent draft and docs payload generation"],
1081+
release_notes_markdown: `${lines.join("\n").trim()}\n`,
1082+
};
1083+
}
1084+
10101085
export function postprocessDraftFromEvidence(draft, evidence) {
10111086
const inputItems = Array.isArray(draft?.release_items)
10121087
? draft.release_items.map(normalizeReleaseItem).filter(Boolean)
@@ -1290,21 +1365,28 @@ export async function main() {
12901365
if (!targetVersion) fail("RELEASE_VERSION is required.");
12911366

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

12981375
const manualNotes = String(process.env.MANUAL_RELEASE_NOTES || "").trim();
12991376
if (manualNotes) {
1300-
writeFileSync(notesPath, ensureSourceHint(validateManualNotes(manualNotes)), "utf8");
1377+
const notes = legacyPackageOnly
1378+
? validateLegacyPackageNotes(manualNotes)
1379+
: ensureSourceHint(validateManualNotes(manualNotes));
1380+
writeFileSync(notesPath, notes, "utf8");
13011381
appendOutput("release_notes_file", notesPath);
13021382
appendOutput("draft_used", "false");
13031383
console.log(`Using manually provided release notes: ${notesPath}`);
13041384
return;
13051385
}
13061386

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

1399+
if (legacyPackageOnly) {
1400+
const draft = legacyPackageDraftFromEvidence(evidence, { npmDistTag });
1401+
const draftPath = join(tmpdir(), `memos-local-plugin-${targetVersion}-release-notes-draft.json`);
1402+
writeFileSync(draftPath, JSON.stringify(draftForInspection(draft), null, 2), "utf8");
1403+
writeFileSync(notesPath, draft.release_notes_markdown, "utf8");
1404+
1405+
appendOutput("release_notes_file", notesPath);
1406+
appendOutput("evidence_file", evidencePath);
1407+
appendOutput("draft_file", draftPath);
1408+
appendOutput("draft_used", "false");
1409+
appendOutput("previous_tag", previousTag);
1410+
appendOutput("current_tag", currentTag);
1411+
appendOutput("current_ref", currentRef);
1412+
appendOutput("draft_confidence", draft.confidence);
1413+
appendOutput("missing_required_count", "0");
1414+
appendOutput("validation_attempt_count", "0");
1415+
appendOutput("repair_attempt_count", "0");
1416+
1417+
console.log(`Generated package-only prerelease notes without Doc Agent: ${notesPath}`);
1418+
console.log(`Previous tag: ${previousTag}`);
1419+
console.log(`Current tag: ${currentTag}`);
1420+
console.log(`Current evidence ref: ${currentRef}`);
1421+
return;
1422+
}
1423+
13171424
const draft = await requestValidatedDraft(evidence);
13181425
if (!draft.ok || draft.needs_review) {
13191426
fail(`Postprocessed release notes require review: ${JSON.stringify(draft.validation_report || draft.coverage || {})}`);

.github/scripts/draft-local-plugin-release-notes.test.mjs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ import {
1010
draftForInspection,
1111
evidenceForInspection,
1212
ensureSourceHint,
13+
isLegacyPackageOnlyRelease,
14+
legacyPackageDraftFromEvidence,
15+
parseSemver,
1316
RELEASE_NOTE_GUIDANCE,
1417
postprocessDraftFromEvidence,
1518
reportExternalFailureFromEnv,
1619
requestDraft,
1720
requestValidatedDraft,
1821
resolveCurrentRef,
22+
selectPreviousTag,
23+
validateLegacyPackageNotes,
1924
validateManualNotes,
2025
versionFromTag,
2126
} from "./draft-local-plugin-release-notes.mjs";
@@ -46,6 +51,70 @@ test("normalizes only real local-plugin tag families", () => {
4651
assert.equal(versionFromTag("v2.0.10"), "");
4752
});
4853

54+
test("detects legacy package-only prereleases", () => {
55+
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13-beta.1", npmDistTag: "beta" }), true);
56+
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13-beta.1", npmDistTag: "latest" }), true);
57+
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "beta" }), true);
58+
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "next" }), true);
59+
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "latest" }), false);
60+
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13", npmDistTag: "" }), false);
61+
});
62+
63+
test("treats SemVer build metadata as stable metadata, not a prerelease channel", () => {
64+
assert.equal(parseSemver("2.0.13+build.7").prerelease, "");
65+
assert.equal(parseSemver("2.0.13-beta.1+build.7").prerelease, "beta.1");
66+
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13+build.7", npmDistTag: "latest" }), false);
67+
assert.equal(isLegacyPackageOnlyRelease({ targetVersion: "2.0.13-beta.1+build.7", npmDistTag: "latest" }), true);
68+
});
69+
70+
test("uses the previous stable tag for docs-generating latest releases", () => {
71+
const tags = [
72+
{ tag: "memos-local-plugin-v2.0.12", version: "2.0.12" },
73+
{ tag: "memos-local-plugin-v2.0.13-beta.1", version: "2.0.13-beta.1" },
74+
{ tag: "memos-local-plugin-v2.0.13", version: "2.0.13" },
75+
];
76+
77+
assert.equal(
78+
selectPreviousTag(tags, "2.0.13", "memos-local-plugin-v2.0.13", { includePrerelease: false }),
79+
"memos-local-plugin-v2.0.12",
80+
);
81+
assert.equal(
82+
selectPreviousTag(tags, "2.0.13", "memos-local-plugin-v2.0.13", { includePrerelease: true }),
83+
"memos-local-plugin-v2.0.13-beta.1",
84+
);
85+
});
86+
87+
test("generates package-only prerelease notes without docs payloads", () => {
88+
const draft = legacyPackageDraftFromEvidence(
89+
{
90+
current_tag: "memos-local-plugin-v2.0.13-beta.1",
91+
previous_tag: "memos-local-plugin-v2.0.12",
92+
target_version: "v2.0.13-beta.1",
93+
git_ref: "abc1234",
94+
changed_files: [{ status: "M", path: "apps/memos-local-plugin/package.json" }],
95+
commits: [
96+
{
97+
sha: "abc1234000000000000000000000000000000000",
98+
short_sha: "abc1234",
99+
subject: "test: prepare local plugin beta package",
100+
},
101+
],
102+
package_changes: [{ field: "version", before: "2.0.12", after: "2.0.13-beta.1" }],
103+
},
104+
{ npmDistTag: "beta" },
105+
);
106+
107+
assert.equal(draft.ok, true);
108+
assert.equal(draft.needs_review, false);
109+
assert.equal(draft.confidence, "legacy-package-only");
110+
assert.equal(draft.coverage.missing_required_count, 0);
111+
assert.match(draft.release_notes_markdown, /## Changelog/);
112+
assert.match(draft.release_notes_markdown, /npm `beta` dist-tag/);
113+
assert.match(draft.release_notes_markdown, /package-only/);
114+
assert.doesNotMatch(draft.release_notes_markdown, /doc-agent: source-id=/);
115+
assert.doesNotMatch(draft.release_notes_markdown, /doc-agent-release-notes-json/);
116+
});
117+
49118
test("uses an existing release tag as the evidence endpoint", () => {
50119
const exists = (ref) => ref === "memos-local-plugin-v2.0.10" || ref === "manual-ref";
51120
assert.equal(
@@ -458,6 +527,69 @@ test("repairs postprocessed language validation issues with exact context", asyn
458527
assert.match(result.release_notes_markdown, /doc-agent-release-notes-json/);
459528
});
460529

530+
test("standalone latest draft validation allows one initial response plus three repairs by default", async () => {
531+
const repairEvidence = {
532+
commits: [
533+
{
534+
sha: "abc12340000000000000000000000000000000",
535+
short_sha: "abc1234",
536+
subject: "feat: add plugin health dashboard (#3001)",
537+
},
538+
],
539+
release_note_guidance: {
540+
source_ref_category_hints: [
541+
{
542+
category: "Added",
543+
source_refs: ["abc1234", "#3001"],
544+
subject: "feat: add plugin health dashboard (#3001)",
545+
},
546+
],
547+
},
548+
};
549+
const badDraft = {
550+
ok: true,
551+
needs_review: false,
552+
release_items: [
553+
{
554+
category: "Added",
555+
text_cn: "Plugin health dashboard",
556+
text_en: "插件健康看板",
557+
source_refs: ["abc1234", "#3001"],
558+
},
559+
],
560+
coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 },
561+
warnings: [],
562+
};
563+
const goodDraft = {
564+
ok: true,
565+
needs_review: false,
566+
release_items: [
567+
{
568+
category: "Added",
569+
text_cn: "**插件健康看板**:新增本地插件健康状态展示。",
570+
text_en: "**Plugin Health Dashboard**: Added local plugin health status visibility.",
571+
source_refs: ["abc1234", "#3001"],
572+
},
573+
],
574+
coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 },
575+
warnings: [],
576+
};
577+
let calls = 0;
578+
const requestImpl = async () => {
579+
calls += 1;
580+
return calls < 4 ? badDraft : goodDraft;
581+
};
582+
583+
const result = await requestValidatedDraft(repairEvidence, { requestImpl });
584+
585+
assert.equal(calls, 4);
586+
assert.equal(result.ok, true);
587+
assert.equal(result.needs_review, false);
588+
assert.equal(result.validation_attempt_count, 4);
589+
assert.equal(result.repair_attempt_count, 3);
590+
assert.match(result.release_notes_markdown, //);
591+
});
592+
461593
test("stops release-note repair after two validation repair attempts", async () => {
462594
const repairEvidence = {
463595
commits: [
@@ -560,6 +692,23 @@ test("manual notes require bilingual evidence refs and passed coverage", () => {
560692
);
561693
});
562694

695+
test("legacy package manual notes reject docs payloads", () => {
696+
const valid = `## Changelog
697+
698+
### Prerelease
699+
- Published MemOS Local Plugin v2.0.13-beta.1 as a beta package.`;
700+
assert.equal(validateLegacyPackageNotes(valid), `${valid}\n`);
701+
assert.throws(
702+
() => validateLegacyPackageNotes(`${valid}\n\n<!-- doc-agent: source-id=openclaw-local-plugin -->`),
703+
/must not include Doc Agent source hints/,
704+
);
705+
assert.throws(
706+
() => validateLegacyPackageNotes(`${valid}\n\n<!-- doc-agent-release-notes-json\n{}\n-->`),
707+
/must not include Doc Agent source hints/,
708+
);
709+
assert.throws(() => validateLegacyPackageNotes("### Prerelease\n- missing changelog"), /Changelog/);
710+
});
711+
563712
test("retries transient draft failures and passes prior error context", async () => {
564713
const previous = { ...process.env };
565714
try {

0 commit comments

Comments
 (0)