mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
Release validation: add isolated campaign skill runner (#129726)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
export type ReleaseValidationCampaignArtifact =
|
||||
| {
|
||||
schema: "openclaw.release-validation-campaign/v1";
|
||||
operation: "upsert";
|
||||
tag: string;
|
||||
stableTrain: string;
|
||||
releaseUrl: string;
|
||||
releaseCommit: string;
|
||||
guidanceMainSha: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
| {
|
||||
schema: "openclaw.release-validation-campaign/v1";
|
||||
operation: "close";
|
||||
tag: string;
|
||||
stableTrain: string;
|
||||
releaseUrl: string;
|
||||
};
|
||||
|
||||
export function validateReleaseValidationCampaignArtifact(
|
||||
artifact: unknown,
|
||||
options?: {
|
||||
expectedTag?: string;
|
||||
expectedReleaseCommit?: string;
|
||||
expectedGuidanceMainSha?: string;
|
||||
},
|
||||
): ReleaseValidationCampaignArtifact;
|
||||
|
||||
export function runReleaseValidationCampaignPublish(params: {
|
||||
github: any;
|
||||
context: { repo: { owner: string; repo: string } };
|
||||
core: { info(message: string): void; setOutput?(name: string, value: string): void };
|
||||
artifact: unknown;
|
||||
expectedTag?: string;
|
||||
expectedReleaseCommit?: string;
|
||||
expectedGuidanceMainSha?: string;
|
||||
campaignIssueNumber?: number;
|
||||
}): Promise<{
|
||||
action: "create" | "update" | "close" | "noop";
|
||||
issueNumber: number | undefined;
|
||||
issueUrl: string | undefined;
|
||||
}>;
|
||||
@@ -0,0 +1,383 @@
|
||||
const CAMPAIGN_SCHEMA = "openclaw.release-validation-campaign/v1";
|
||||
const CAMPAIGN_LABEL = "release-validation";
|
||||
const FINDING_LABEL = "release-validation-finding";
|
||||
const CAMPAIGN_LABEL_COLOR = "0E8A16";
|
||||
const FINDING_LABEL_COLOR = "D93F0B";
|
||||
const MAX_BODY_BYTES = 60_000;
|
||||
const BETA_TAG_PATTERN = /^v(\d{4})\.(\d+)\.(\d+)-beta\.([1-9]\d*)$/u;
|
||||
const STABLE_TAG_PATTERN = /^v(\d{4})\.(\d+)\.(\d+)$/u;
|
||||
const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/u;
|
||||
const PLACEHOLDER_PATTERN = /\{\{([A-Z0-9_]+)\}\}/gu;
|
||||
const ALLOWED_PLACEHOLDERS = new Set(["OPENCLAW", "RESTART_GATEWAY"]);
|
||||
|
||||
function labelName(label) {
|
||||
return typeof label === "string" ? label : label?.name;
|
||||
}
|
||||
|
||||
function countOccurrences(value, needle) {
|
||||
return value.split(needle).length - 1;
|
||||
}
|
||||
|
||||
function assertExactKeys(value, allowedKeys) {
|
||||
const unexpected = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
||||
if (unexpected.length > 0) {
|
||||
throw new Error(`Campaign artifact has unexpected field(s): ${unexpected.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(value, field) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Campaign artifact field ${field} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseReleaseTag(tag) {
|
||||
const beta = BETA_TAG_PATTERN.exec(tag);
|
||||
if (beta) {
|
||||
const stableTrain = `v${beta[1]}.${beta[2]}.${beta[3]}`;
|
||||
return { kind: "beta", stableTrain, displayVersion: stableTrain.slice(1) };
|
||||
}
|
||||
const stable = STABLE_TAG_PATTERN.exec(tag);
|
||||
if (stable) {
|
||||
const stableTrain = `v${stable[1]}.${stable[2]}.${stable[3]}`;
|
||||
return { kind: "stable", stableTrain, displayVersion: stableTrain.slice(1) };
|
||||
}
|
||||
throw new Error(`Unsupported release-validation campaign tag: ${tag}`);
|
||||
}
|
||||
|
||||
function validateBetaBody(body, { tag, stableTrain, releaseUrl, releaseCommit, guidanceMainSha }) {
|
||||
if (Buffer.byteLength(body) > MAX_BODY_BYTES) {
|
||||
throw new Error("Release-validation campaign body exceeds the issue-body safety limit");
|
||||
}
|
||||
|
||||
const marker = `<!-- openclaw-release-validation:${stableTrain} -->`;
|
||||
const requiredOnce = [
|
||||
marker,
|
||||
`- Current beta: [${tag}](${releaseUrl})`,
|
||||
`- Beta commit: \`${releaseCommit}\``,
|
||||
`- Guidance main commit: \`${guidanceMainSha}\``,
|
||||
"- Test target: latest immutable `origin/main`",
|
||||
"<!-- validation-guidance:start -->",
|
||||
"<!-- validation-guidance:end -->",
|
||||
"## Priority surfaces for this release",
|
||||
`## Priority surfaces since ${tag}`,
|
||||
"## Participate",
|
||||
];
|
||||
for (const required of requiredOnce) {
|
||||
if (countOccurrences(body, required) !== 1) {
|
||||
throw new Error(`Campaign body must contain exactly one ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (countOccurrences(body, "| **Testing notes**") !== 6) {
|
||||
throw new Error("Campaign body must contain exactly six empty testing-notes rows");
|
||||
}
|
||||
for (const line of body.split("\n").filter((candidate) => candidate.includes("Testing notes"))) {
|
||||
if (!/^\| \*\*Testing notes\*\*\s+\|\s+\|$/u.test(line)) {
|
||||
throw new Error("Every campaign testing-notes row must be empty");
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of body.matchAll(PLACEHOLDER_PATTERN)) {
|
||||
if (!ALLOWED_PLACEHOLDERS.has(match[1])) {
|
||||
throw new Error(`Campaign body contains unsupported placeholder {{${match[1]}}}`);
|
||||
}
|
||||
}
|
||||
if (body.includes("{{TEST_ENV}}")) {
|
||||
throw new Error("Campaign body contains the retired TEST_ENV placeholder");
|
||||
}
|
||||
if (/(?:file:\/\/|\/(?:Users|home)\/[^\s)]+|[A-Za-z]:\\Users\\[^\s)]+)/u.test(body)) {
|
||||
throw new Error("Campaign body contains a local filesystem path");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateReleaseValidationCampaignArtifact(
|
||||
artifact,
|
||||
{ expectedTag, expectedReleaseCommit, expectedGuidanceMainSha } = {},
|
||||
) {
|
||||
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
|
||||
throw new Error("Release-validation campaign artifact must be an object");
|
||||
}
|
||||
|
||||
const schema = requireString(artifact.schema, "schema");
|
||||
if (schema !== CAMPAIGN_SCHEMA) {
|
||||
throw new Error(`Unsupported release-validation campaign schema: ${schema}`);
|
||||
}
|
||||
const tag = requireString(artifact.tag, "tag");
|
||||
if (expectedTag !== undefined && tag !== expectedTag) {
|
||||
throw new Error(`Campaign artifact tag ${tag} does not match ${expectedTag}`);
|
||||
}
|
||||
const parsedTag = parseReleaseTag(tag);
|
||||
const stableTrain = requireString(artifact.stableTrain, "stableTrain");
|
||||
if (stableTrain !== parsedTag.stableTrain) {
|
||||
throw new Error(`Campaign artifact stable train ${stableTrain} does not match ${tag}`);
|
||||
}
|
||||
const releaseUrl = requireString(artifact.releaseUrl, "releaseUrl");
|
||||
const expectedReleaseUrl = `https://github.com/openclaw/openclaw/releases/tag/${tag}`;
|
||||
if (releaseUrl !== expectedReleaseUrl) {
|
||||
throw new Error(`Campaign artifact release URL must be ${expectedReleaseUrl}`);
|
||||
}
|
||||
const operation = requireString(artifact.operation, "operation");
|
||||
|
||||
if (parsedTag.kind === "stable") {
|
||||
assertExactKeys(artifact, new Set(["schema", "operation", "tag", "stableTrain", "releaseUrl"]));
|
||||
if (operation !== "close") {
|
||||
throw new Error("A stable campaign artifact must use the close operation");
|
||||
}
|
||||
return { schema, operation, tag, stableTrain, releaseUrl };
|
||||
}
|
||||
|
||||
assertExactKeys(
|
||||
artifact,
|
||||
new Set([
|
||||
"schema",
|
||||
"operation",
|
||||
"tag",
|
||||
"stableTrain",
|
||||
"releaseUrl",
|
||||
"releaseCommit",
|
||||
"guidanceMainSha",
|
||||
"title",
|
||||
"body",
|
||||
]),
|
||||
);
|
||||
if (operation !== "upsert") {
|
||||
throw new Error("A beta campaign artifact must use the upsert operation");
|
||||
}
|
||||
const releaseCommit = requireString(artifact.releaseCommit, "releaseCommit");
|
||||
const guidanceMainSha = requireString(artifact.guidanceMainSha, "guidanceMainSha");
|
||||
if (!FULL_SHA_PATTERN.test(releaseCommit) || !FULL_SHA_PATTERN.test(guidanceMainSha)) {
|
||||
throw new Error("Campaign artifact commits must be full lowercase Git SHAs");
|
||||
}
|
||||
if (expectedReleaseCommit !== undefined && releaseCommit !== expectedReleaseCommit) {
|
||||
throw new Error("Campaign artifact release commit does not match the release tag");
|
||||
}
|
||||
if (expectedGuidanceMainSha !== undefined && guidanceMainSha !== expectedGuidanceMainSha) {
|
||||
throw new Error("Campaign artifact guidance main SHA does not match the workflow checkout");
|
||||
}
|
||||
const title = requireString(artifact.title, "title");
|
||||
const expectedTitle = `OpenClaw ${parsedTag.displayVersion} beta feedback`;
|
||||
if (title !== expectedTitle) {
|
||||
throw new Error(`Campaign artifact title must be ${expectedTitle}`);
|
||||
}
|
||||
const body = requireString(artifact.body, "body");
|
||||
validateBetaBody(body, { tag, stableTrain, releaseUrl, releaseCommit, guidanceMainSha });
|
||||
return {
|
||||
schema,
|
||||
operation,
|
||||
tag,
|
||||
stableTrain,
|
||||
releaseUrl,
|
||||
releaseCommit,
|
||||
guidanceMainSha,
|
||||
title,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureLabel({ github, owner, repo, name, color, description }) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name });
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color, description });
|
||||
}
|
||||
}
|
||||
|
||||
function hasMarker(issue, stableTrain) {
|
||||
return issue.body?.includes(`<!-- openclaw-release-validation:${stableTrain} -->`);
|
||||
}
|
||||
|
||||
function hasLegacyMarker(issue, stableTrain) {
|
||||
const escapedTrain = stableTrain.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
return new RegExp(
|
||||
`<!-- openclaw-release-validation:${escapedTrain}-beta\\.[1-9]\\d* -->`,
|
||||
"u",
|
||||
).test(issue.body ?? "");
|
||||
}
|
||||
|
||||
function betaNumberFromBody(body, stableTrain) {
|
||||
const escapedTrain = stableTrain.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
const match = new RegExp(
|
||||
`^- Current beta: \\[${escapedTrain}-beta\\.([1-9]\\d*)\\]\\(`,
|
||||
"mu",
|
||||
).exec(body ?? "");
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
async function closeCampaign({ github, owner, repo, issue, comment }) {
|
||||
if (comment) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
body: comment,
|
||||
});
|
||||
}
|
||||
const labels = (issue.labels ?? [])
|
||||
.map(labelName)
|
||||
.filter((name) => name && name !== CAMPAIGN_LABEL);
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "completed",
|
||||
labels,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runReleaseValidationCampaignPublish({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
artifact,
|
||||
expectedTag,
|
||||
expectedReleaseCommit,
|
||||
expectedGuidanceMainSha,
|
||||
campaignIssueNumber,
|
||||
}) {
|
||||
const validated = validateReleaseValidationCampaignArtifact(artifact, {
|
||||
expectedTag,
|
||||
expectedReleaseCommit,
|
||||
expectedGuidanceMainSha,
|
||||
});
|
||||
const { owner, repo } = context.repo;
|
||||
const labeled = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
labels: CAMPAIGN_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
const openCampaigns = labeled.filter((issue) => !issue.pull_request);
|
||||
let matching = openCampaigns.filter((issue) => hasMarker(issue, validated.stableTrain));
|
||||
if (campaignIssueNumber !== undefined) {
|
||||
const { data: requestedIssue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: campaignIssueNumber,
|
||||
});
|
||||
if (
|
||||
requestedIssue.pull_request ||
|
||||
requestedIssue.state !== "open" ||
|
||||
(!hasMarker(requestedIssue, validated.stableTrain) &&
|
||||
!hasLegacyMarker(requestedIssue, validated.stableTrain))
|
||||
) {
|
||||
throw new Error(
|
||||
`Requested campaign issue #${campaignIssueNumber} is not an open ${validated.stableTrain} campaign`,
|
||||
);
|
||||
}
|
||||
if (matching.some((issue) => issue.number !== requestedIssue.number)) {
|
||||
throw new Error(
|
||||
`Requested campaign issue #${campaignIssueNumber} conflicts with the labeled campaign`,
|
||||
);
|
||||
}
|
||||
matching = [requestedIssue];
|
||||
}
|
||||
if (matching.length > 1) {
|
||||
throw new Error(`Multiple open campaigns match ${validated.stableTrain}`);
|
||||
}
|
||||
|
||||
if (validated.operation === "close") {
|
||||
if (matching.length === 0) {
|
||||
core.info(`No open ${validated.stableTrain} campaign remains to close.`);
|
||||
return { action: "noop", issueNumber: undefined, issueUrl: undefined };
|
||||
}
|
||||
await closeCampaign({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issue: matching[0],
|
||||
comment: `Stable release: ${validated.releaseUrl}`,
|
||||
});
|
||||
core.info(`Closed release-validation campaign #${matching[0].number}.`);
|
||||
return {
|
||||
action: "close",
|
||||
issueNumber: matching[0].number,
|
||||
issueUrl: matching[0].html_url,
|
||||
};
|
||||
}
|
||||
|
||||
await ensureLabel({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
name: CAMPAIGN_LABEL,
|
||||
color: CAMPAIGN_LABEL_COLOR,
|
||||
description: "Canonical OpenClaw release-validation campaign",
|
||||
});
|
||||
await ensureLabel({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
name: FINDING_LABEL,
|
||||
color: FINDING_LABEL_COLOR,
|
||||
description: "Bug found during release-validation testing",
|
||||
});
|
||||
|
||||
let issue;
|
||||
let action;
|
||||
if (matching.length === 1) {
|
||||
const currentBetaNumber = betaNumberFromBody(matching[0].body, validated.stableTrain);
|
||||
const nextBetaNumber = Number(BETA_TAG_PATTERN.exec(validated.tag)?.[4]);
|
||||
if (currentBetaNumber !== undefined && nextBetaNumber < currentBetaNumber) {
|
||||
throw new Error(
|
||||
`Refusing to replace beta.${currentBetaNumber} campaign guidance with older ${validated.tag}`,
|
||||
);
|
||||
}
|
||||
const { data } = await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: matching[0].number,
|
||||
title: validated.title,
|
||||
body: validated.body,
|
||||
state: "open",
|
||||
labels: [CAMPAIGN_LABEL],
|
||||
});
|
||||
issue = data;
|
||||
action = "update";
|
||||
} else {
|
||||
const { data } = await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title: validated.title,
|
||||
body: validated.body,
|
||||
labels: [CAMPAIGN_LABEL],
|
||||
});
|
||||
issue = data;
|
||||
action = "create";
|
||||
}
|
||||
|
||||
const { data: readback } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
if (
|
||||
readback.state !== "open" ||
|
||||
readback.title !== validated.title ||
|
||||
readback.body !== validated.body ||
|
||||
readback.labels?.length !== 1 ||
|
||||
labelName(readback.labels[0]) !== CAMPAIGN_LABEL
|
||||
) {
|
||||
throw new Error(`Campaign issue #${issue.number} failed post-write verification`);
|
||||
}
|
||||
|
||||
for (const older of openCampaigns.filter((candidate) => candidate.number !== issue.number)) {
|
||||
await closeCampaign({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issue: older,
|
||||
comment: `Superseded by ${readback.html_url}`,
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`${action === "create" ? "Created" : "Updated"} ${readback.html_url}.`);
|
||||
core.setOutput?.("issue-url", readback.html_url);
|
||||
return { action, issueNumber: readback.number, issueUrl: readback.html_url };
|
||||
}
|
||||
Reference in New Issue
Block a user