mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 01:01:40 +00:00
fix(release): harden publication validation
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
// Package Acceptance Workflow tests cover package acceptance workflow script behavior.
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
@@ -601,6 +602,44 @@ describe("package acceptance workflow", () => {
|
||||
});
|
||||
|
||||
describe("package artifact reuse", () => {
|
||||
it("keeps every tracked repository skill visible to Git-aware syncs", () => {
|
||||
const gitignore = readFileSync(".gitignore", "utf8");
|
||||
const skillFiles = execFileSync("git", ["ls-files", ".agents/skills/*/SKILL.md"], {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.trim()
|
||||
.split("\n");
|
||||
const skillDirs = skillFiles.map((path) => path.split("/").slice(0, 3).join("/"));
|
||||
|
||||
for (const skillDir of skillDirs) {
|
||||
expect(gitignore).toContain(`!${skillDir}/`);
|
||||
expect(gitignore).toContain(`!${skillDir}/**`);
|
||||
}
|
||||
const ignored = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], {
|
||||
encoding: "utf8",
|
||||
input: `${skillFiles.join("\n")}\n`,
|
||||
});
|
||||
expect(ignored.status).toBe(1);
|
||||
expect(ignored.stdout).toBe("");
|
||||
expect(ignored.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("keeps tracked sync metadata and QA Mantis sources visible to remote full syncs", () => {
|
||||
for (const path of [
|
||||
".gitignore",
|
||||
"apps/android/.gitignore",
|
||||
"extensions/qa-lab/src/mantis/cli.ts",
|
||||
]) {
|
||||
const result = spawnSync("git", ["check-ignore", "--no-index", path], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("lets reusable Docker E2E consume an already resolved package artifact", () => {
|
||||
const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8");
|
||||
const packageJson = readFileSync(PACKAGE_JSON, "utf8");
|
||||
@@ -1980,8 +2019,56 @@ describe("package artifact reuse", () => {
|
||||
expect(npmTelegramWorkflow).toContain("package artifact digest mismatch");
|
||||
expect(workflow).toContain("Checkout release SHA");
|
||||
expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"');
|
||||
expect(workflow).toContain('$0 == "## Unreleased" { in_section = 1; next }');
|
||||
expect(workflow).toContain("Unreleased prerelease fallback");
|
||||
expect(workflow).toContain("render_github_release_notes()");
|
||||
expect(workflow).toContain("node scripts/render-github-release-notes.mjs");
|
||||
expect(workflow).toContain('--tag "${RELEASE_TAG}"');
|
||||
expect(workflow).toContain('--repository "${GITHUB_REPOSITORY}"');
|
||||
expect(workflow).toContain('render_args+=(--verification-file "${verification_file}")');
|
||||
expect(workflow).toContain('render_args+=(--metadata-output "${metadata_file}")');
|
||||
expect(workflow).toContain('--notes-file "${prepared_release_notes_file}"');
|
||||
expect(workflow).not.toContain("return_run_details: true");
|
||||
expect(workflow).toContain("API 2026-03-10 removed return_run_details");
|
||||
expect(workflow).toContain("Accept: application/vnd.github+json");
|
||||
expect(workflow).toContain("X-GitHub-Api-Version: 2026-03-10");
|
||||
expect(workflow).toContain("'.workflow_run_id'");
|
||||
expect(workflow).toContain("'.html_url'");
|
||||
expect(workflow).not.toContain("gh workflow run --repo");
|
||||
expect(workflow).toContain("verify_release_tag_target()");
|
||||
expect(workflow).toContain("git ls-remote --tags origin");
|
||||
expect(workflow).toContain("Release tag ${RELEASE_TAG} moved:");
|
||||
expect(workflow).toContain("canonical_release_body_matches()");
|
||||
expect(workflow).toContain('has_canonical_body="true"');
|
||||
expect(workflow).toContain('"${has_canonical_body}" == "true"');
|
||||
expect(workflow).toContain('"${has_sha}" == "true"');
|
||||
expect(workflow).toContain('"${has_proof}" == "true"');
|
||||
expect(workflow).toContain('"${has_asset}" == "true"');
|
||||
expect(workflow).toContain('prerelease_arg="--prerelease=false"');
|
||||
expect(workflow).toContain('latest_arg="--latest=false"');
|
||||
expect(workflow).toContain("--json isDraft,isPrerelease");
|
||||
expect(workflow).toContain("verificationIncluded == true");
|
||||
expect(workflow).toContain(
|
||||
"Release verification proof omitted because the canonical release notes already reach GitHub's body limit.",
|
||||
);
|
||||
expect(workflow).not.toContain('$0 == "## " version { in_section = 1; next }');
|
||||
expect(workflow).not.toContain("Unreleased prerelease fallback");
|
||||
const releaseNotesPreflightIndex = workflow.indexOf(
|
||||
'prepared_release_notes_file="${RUNNER_TEMP}/release-notes-prepublish.md"',
|
||||
);
|
||||
const publishGateIndex = workflow.indexOf(
|
||||
'if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then',
|
||||
releaseNotesPreflightIndex,
|
||||
);
|
||||
const releaseNotesRenderIndex = workflow.indexOf(
|
||||
"render_github_release_notes \\",
|
||||
releaseNotesPreflightIndex,
|
||||
);
|
||||
const firstPublishDispatchIndex = workflow.indexOf(
|
||||
'plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml',
|
||||
);
|
||||
expect(releaseNotesPreflightIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(publishGateIndex).toBeGreaterThan(releaseNotesPreflightIndex);
|
||||
expect(releaseNotesRenderIndex).toBeGreaterThan(publishGateIndex);
|
||||
expect(firstPublishDispatchIndex).toBeGreaterThan(releaseNotesPreflightIndex);
|
||||
expect(workflow).not.toContain("gh api --repo");
|
||||
expect(workflow).not.toContain("timeout-minutes: 360");
|
||||
});
|
||||
@@ -2370,9 +2457,12 @@ describe("package artifact reuse", () => {
|
||||
expect(clawHubMetadataIndex).toBeGreaterThan(clawHubSetupIndex);
|
||||
expect(releaseWorkflow).toContain("Plugin npm run ID");
|
||||
expect(releaseWorkflow).toContain("Plugin ClawHub run ID");
|
||||
expect(releaseWorkflow).toContain(
|
||||
expect(releaseWorkflow).not.toContain(
|
||||
"did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs",
|
||||
);
|
||||
expect(releaseWorkflow).not.toContain("return_run_details: true");
|
||||
expect(releaseWorkflow).toContain("'.workflow_run_id'");
|
||||
expect(releaseWorkflow).toContain("'.html_url'");
|
||||
expect(releaseWorkflow).not.toContain("BEFORE_IDS=");
|
||||
expect(releaseWorkflow).not.toContain("before_json");
|
||||
expect(releaseWorkflow).toContain("plugin-clawhub-new.yml");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Release Candidate Checklist tests cover release candidate checklist script behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildPublishCommand,
|
||||
candidateCumulativeShippedPullRequests,
|
||||
candidateParallelsArgs,
|
||||
candidateParallelsShellCommand,
|
||||
githubApi,
|
||||
@@ -9,6 +11,10 @@ import {
|
||||
parseRunIdFromDispatchOutput,
|
||||
resolveArtifactName,
|
||||
requireRunIdFromDispatchOutput,
|
||||
run,
|
||||
validateCandidateChangelogProvenance,
|
||||
validateCandidateCheckout,
|
||||
validateCandidateReleaseNotes,
|
||||
validateFullManifest,
|
||||
validatePreflightManifest,
|
||||
validateWindowsSourceRelease,
|
||||
@@ -33,6 +39,290 @@ async function withGithubApiTimeoutEnv<T>(value: string, fn: () => Promise<T>):
|
||||
}
|
||||
|
||||
describe("release candidate checklist", () => {
|
||||
it("captures changelogs larger than the Node spawnSync default buffer", () => {
|
||||
const output = run(
|
||||
process.execPath,
|
||||
["-e", "process.stdout.write('x'.repeat(2 * 1024 * 1024))"],
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
expect(output).toHaveLength(2 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("requires candidate tooling to come from the clean tagged checkout", () => {
|
||||
expect(
|
||||
validateCandidateCheckout({
|
||||
targetSha: "a".repeat(40),
|
||||
headSha: "a".repeat(40),
|
||||
trackedStatus: "",
|
||||
}),
|
||||
).toEqual({ status: "passed", targetSha: "a".repeat(40) });
|
||||
expect(() =>
|
||||
validateCandidateCheckout({
|
||||
targetSha: "a".repeat(40),
|
||||
headSha: "b".repeat(40),
|
||||
trackedStatus: "",
|
||||
}),
|
||||
).toThrow("but HEAD is");
|
||||
expect(() =>
|
||||
validateCandidateCheckout({
|
||||
targetSha: "a".repeat(40),
|
||||
headSha: "a".repeat(40),
|
||||
trackedStatus: " M scripts/release-candidate-checklist.mjs",
|
||||
}),
|
||||
).toThrow("requires a clean tracked worktree");
|
||||
});
|
||||
|
||||
it("validates the exact tag changelog before dispatching the release matrix", () => {
|
||||
const check = validateCandidateReleaseNotes({
|
||||
changelog: [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- User-facing notes.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`- **PR #123** ${"record ".repeat(20_000)}`,
|
||||
].join("\n"),
|
||||
repository: "openclaw/openclaw",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
});
|
||||
const source = readFileSync("scripts/release-candidate-checklist.mjs", "utf8");
|
||||
const validationIndex = source.indexOf(
|
||||
"const releaseNotesCheck = validateCandidateReleaseNotes",
|
||||
);
|
||||
const fullMatrixDispatchIndex = source.indexOf(
|
||||
"if (!options.fullReleaseRunId && !options.skipDispatch)",
|
||||
);
|
||||
|
||||
expect(check).toMatchObject({ status: "passed", mode: "compact" });
|
||||
expect(validationIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(fullMatrixDispatchIndex).toBeGreaterThan(validationIndex);
|
||||
expect(source).toContain('run("git", ["show", `${targetSha}:CHANGELOG.md`]');
|
||||
});
|
||||
|
||||
it("rejects contribution-record provenance outside the release tag history", () => {
|
||||
const base = "v2026.6.11";
|
||||
const recordedTarget = "a".repeat(40);
|
||||
const targetSha = "b".repeat(40);
|
||||
const changelog = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- User-facing notes.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete ${base}..${recordedTarget} history: 1 merged PR.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #123** fix: example.",
|
||||
].join("\n");
|
||||
const reachable = vi.fn((ancestor: string, target: string) => {
|
||||
return ancestor === base && target === recordedTarget;
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
validateCandidateChangelogProvenance({
|
||||
changelog,
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
targetSha,
|
||||
isAncestor: reachable,
|
||||
}),
|
||||
).toThrow(`contribution record target ${recordedTarget} is not reachable`);
|
||||
expect(reachable).toHaveBeenCalledWith(base, recordedTarget);
|
||||
expect(reachable).toHaveBeenCalledWith(recordedTarget, targetSha);
|
||||
});
|
||||
|
||||
it("rejects duplicate contribution record rows even when the declared count matches", () => {
|
||||
const targetSha = "b".repeat(40);
|
||||
const changelog = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- User-facing notes.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete base..${targetSha} history: 1 merged PR.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #123** fix: example.",
|
||||
"- **PR #123** fix: duplicate.",
|
||||
].join("\n");
|
||||
|
||||
expect(() =>
|
||||
validateCandidateChangelogProvenance({
|
||||
changelog,
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
targetSha,
|
||||
isAncestor: () => true,
|
||||
}),
|
||||
).toThrow("duplicate contribution record PR rows: #123");
|
||||
});
|
||||
|
||||
it("uses numbered historical record rows and skips Unreleased baseline rows", () => {
|
||||
const changelog = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## Unreleased",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
"This audited record covers the complete base..HEAD history: 99 merged PRs.",
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #1** fix: not shipped.",
|
||||
"",
|
||||
"## 2026.6.11",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
"This audited record covers the complete base..HEAD history: 0 merged PRs.",
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #2** fix: shipped.",
|
||||
].join("\n");
|
||||
|
||||
expect([...candidateCumulativeShippedPullRequests(changelog, "test baseline")]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("validates cumulative shipped baseline exclusion metadata", () => {
|
||||
const base = "66e676d29b92d040716376a75aca32bad655cfac";
|
||||
const recordedTarget = "a".repeat(40);
|
||||
const changelog = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- User-facing notes.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete ${base}..${recordedTarget} history: 1 merged PR.`,
|
||||
"",
|
||||
"Shipped baseline exclusions: v2026.6.11 (8 PRs: #101, #102, #103, #104, #105, #106, #107, #108).",
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #123** fix: example.",
|
||||
].join("\n");
|
||||
const shippedPullRequests = new Set([101, 102, 103, 104, 105, 106, 107, 108]);
|
||||
const loadShippedBaseline = vi.fn(() => ({
|
||||
ref: "v2026.6.11",
|
||||
pullRequests: shippedPullRequests,
|
||||
}));
|
||||
expect(
|
||||
validateCandidateChangelogProvenance({
|
||||
changelog,
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
targetSha: recordedTarget,
|
||||
isAncestor: () => true,
|
||||
loadShippedBaseline,
|
||||
}),
|
||||
).toEqual({
|
||||
status: "passed",
|
||||
base,
|
||||
target: recordedTarget,
|
||||
shippedBaselines: [
|
||||
{
|
||||
ref: "v2026.6.11",
|
||||
count: 8,
|
||||
pullRequests: [101, 102, 103, 104, 105, 106, 107, 108],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(loadShippedBaseline).toHaveBeenCalledWith("v2026.6.11");
|
||||
|
||||
expect(() =>
|
||||
validateCandidateChangelogProvenance({
|
||||
changelog: changelog.replace("8 PRs:", "8 pull requests:"),
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
targetSha: recordedTarget,
|
||||
isAncestor: () => true,
|
||||
loadShippedBaseline,
|
||||
}),
|
||||
).toThrow("malformed shipped baseline exclusion");
|
||||
expect(() =>
|
||||
validateCandidateChangelogProvenance({
|
||||
changelog,
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
targetSha: recordedTarget,
|
||||
isAncestor: () => true,
|
||||
loadShippedBaseline: () => ({
|
||||
ref: "v2026.6.11",
|
||||
pullRequests: new Set([...shippedPullRequests].slice(1)),
|
||||
}),
|
||||
}),
|
||||
).toThrow("lists PRs absent from shipped baseline v2026.6.11: #101");
|
||||
expect(() =>
|
||||
validateCandidateChangelogProvenance({
|
||||
changelog: changelog.replace(
|
||||
"- **PR #123** fix: example.",
|
||||
"- **PR #101** fix: already shipped.",
|
||||
),
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
targetSha: recordedTarget,
|
||||
isAncestor: () => true,
|
||||
loadShippedBaseline,
|
||||
}),
|
||||
).toThrow("still contains shipped PRs from v2026.6.11: #101");
|
||||
});
|
||||
|
||||
it("requires contribution records for beta candidates but permits alpha Unreleased fallback", () => {
|
||||
const betaChangelog = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- User-facing notes.",
|
||||
].join("\n");
|
||||
expect(() =>
|
||||
validateCandidateChangelogProvenance({
|
||||
changelog: betaChangelog,
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-beta.3",
|
||||
targetSha: "a".repeat(40),
|
||||
}),
|
||||
).toThrow("missing ### Complete contribution record");
|
||||
|
||||
const alpha = validateCandidateChangelogProvenance({
|
||||
changelog: betaChangelog.replace("## 2026.7.1", "## Unreleased"),
|
||||
version: "2026.7.1",
|
||||
tag: "v2026.7.1-alpha.1",
|
||||
targetSha: "a".repeat(40),
|
||||
});
|
||||
expect(alpha).toEqual({
|
||||
status: "skipped",
|
||||
reason: "alpha release uses the explicit Unreleased fallback",
|
||||
shippedBaselines: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("infers validation profiles from candidate tags", () => {
|
||||
expect(parseArgs(["--tag", "v2026.5.14-beta.3"]).releaseProfile).toBe("beta");
|
||||
expect(parseArgs(["--tag", "v2026.5.14", "--windows-node-tag", "v0.6.3"]).releaseProfile).toBe(
|
||||
|
||||
345
test/scripts/render-github-release-notes.test.ts
Normal file
345
test/scripts/render-github-release-notes.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GITHUB_RELEASE_BODY_MAX_BYTES,
|
||||
GITHUB_RELEASE_BODY_MAX_CHARACTERS,
|
||||
extractChangelogSection,
|
||||
formatShippedBaselineExclusions,
|
||||
parseShippedBaselineExclusions,
|
||||
releaseNotesVersionForTag,
|
||||
renderGithubReleaseNotes,
|
||||
verifyGithubReleaseNotes,
|
||||
} from "../../scripts/render-github-release-notes.mjs";
|
||||
|
||||
const repository = "openclaw/openclaw";
|
||||
const tag = "v2026.7.1-beta.3";
|
||||
const version = "2026.7.1";
|
||||
|
||||
function changelogFor(record: string): string {
|
||||
return [
|
||||
"# Changelog",
|
||||
"",
|
||||
`## ${version}`,
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- A grouped user-facing highlight.",
|
||||
"",
|
||||
"### Fixes",
|
||||
"",
|
||||
"- A grouped user-facing fix.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
record,
|
||||
"",
|
||||
"## 2026.6.11",
|
||||
"",
|
||||
"- Previous release.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
describe("GitHub release-note rendering", () => {
|
||||
it("emits the complete matching section including its version heading when it fits", () => {
|
||||
const rendered = renderGithubReleaseNotes({
|
||||
changelog: changelogFor("- **PR #123** fix: example. Thanks @contributor."),
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
});
|
||||
|
||||
expect(rendered.mode).toBe("full");
|
||||
expect(rendered.body).toBe(
|
||||
[
|
||||
`## ${version}`,
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- A grouped user-facing highlight.",
|
||||
"",
|
||||
"### Fixes",
|
||||
"",
|
||||
"- A grouped user-facing fix.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
"- **PR #123** fix: example. Thanks @contributor.",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces an oversized contribution record with a tag-pinned link", () => {
|
||||
const oversizedRecord = `- **PR #123** ${"record-only-detail ".repeat(9_000)}`;
|
||||
const rendered = renderGithubReleaseNotes({
|
||||
changelog: changelogFor(oversizedRecord),
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
});
|
||||
|
||||
expect(rendered.mode).toBe("compact");
|
||||
expect(rendered.body).toContain(`## ${version}\n\n### Highlights`);
|
||||
expect(rendered.body).toContain("- A grouped user-facing fix.");
|
||||
expect(rendered.body).toContain("### Complete contribution record");
|
||||
expect(rendered.body).toContain(
|
||||
"https://github.com/openclaw/openclaw/blob/v2026.7.1-beta.3/CHANGELOG.md#complete-contribution-record",
|
||||
);
|
||||
expect(rendered.body).not.toContain("record-only-detail");
|
||||
expect(rendered.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS);
|
||||
expect(rendered.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES);
|
||||
});
|
||||
|
||||
it("keeps a fitting full section and omits only a proof tail that would overflow", () => {
|
||||
const nearlyFullRecord = `- **PR #123** ${"x".repeat(124_500)}`;
|
||||
const changelog = changelogFor(nearlyFullRecord);
|
||||
const withoutProof = renderGithubReleaseNotes({
|
||||
changelog,
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
});
|
||||
const withProof = renderGithubReleaseNotes({
|
||||
changelog,
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
verification: `### Release verification\n\n- proof: ${"y".repeat(1_000)}`,
|
||||
});
|
||||
|
||||
expect(withoutProof.mode).toBe("full");
|
||||
expect(withProof.mode).toBe("full");
|
||||
expect(withProof.verificationIncluded).toBe(false);
|
||||
expect(withProof.verificationOmitted).toBe(true);
|
||||
expect(withProof.body).toBe(withoutProof.body);
|
||||
expect(withProof.body).not.toContain("### Release verification");
|
||||
expect(withProof.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS);
|
||||
expect(withProof.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES);
|
||||
});
|
||||
|
||||
it("uses the full form at exactly 125,000 bytes and compacts at 125,001", () => {
|
||||
const seed = renderGithubReleaseNotes({
|
||||
changelog: changelogFor("x"),
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
});
|
||||
const exactRecordLength =
|
||||
GITHUB_RELEASE_BODY_MAX_BYTES - seed.size.bytes + Buffer.byteLength("x");
|
||||
const exact = renderGithubReleaseNotes({
|
||||
changelog: changelogFor("x".repeat(exactRecordLength)),
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
});
|
||||
const over = renderGithubReleaseNotes({
|
||||
changelog: changelogFor("x".repeat(exactRecordLength + 1)),
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
});
|
||||
|
||||
expect(exact.mode).toBe("full");
|
||||
expect(exact.size).toEqual({
|
||||
characters: GITHUB_RELEASE_BODY_MAX_CHARACTERS,
|
||||
bytes: GITHUB_RELEASE_BODY_MAX_BYTES,
|
||||
});
|
||||
expect(over.mode).toBe("compact");
|
||||
});
|
||||
|
||||
it("compacts when multibyte text exceeds the byte limit before the character limit", () => {
|
||||
const rendered = renderGithubReleaseNotes({
|
||||
changelog: changelogFor("é".repeat(63_000)),
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
});
|
||||
|
||||
expect(rendered.mode).toBe("compact");
|
||||
expect(rendered.size.bytes).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_BYTES);
|
||||
expect(rendered.size.characters).toBeLessThanOrEqual(GITHUB_RELEASE_BODY_MAX_CHARACTERS);
|
||||
});
|
||||
|
||||
it("normalizes correction tags to the stable changelog section", () => {
|
||||
expect(releaseNotesVersionForTag("v2026.7.1-2")).toBe("2026.7.1");
|
||||
const rendered = renderGithubReleaseNotes({
|
||||
changelog: changelogFor("- **PR #123** fix: correction."),
|
||||
version,
|
||||
tag: "v2026.7.1-2",
|
||||
repository,
|
||||
});
|
||||
|
||||
expect(rendered.body).toContain("## 2026.7.1");
|
||||
});
|
||||
|
||||
it("round-trips canonical shipped baseline exclusions and rejects malformed metadata", () => {
|
||||
const line = formatShippedBaselineExclusions([
|
||||
{ ref: "v2026.6.11", count: 2, pullRequests: [108, 101] },
|
||||
{ ref: "v2026.6.10-beta.2", count: 0, pullRequests: [] },
|
||||
]);
|
||||
|
||||
expect(line).toBe(
|
||||
"Shipped baseline exclusions: v2026.6.10-beta.2 (0 PRs); v2026.6.11 (2 PRs: #101, #108).",
|
||||
);
|
||||
expect(parseShippedBaselineExclusions(line)).toEqual([
|
||||
{ ref: "v2026.6.10-beta.2", count: 0, pullRequests: [] },
|
||||
{ ref: "v2026.6.11", count: 2, pullRequests: [101, 108] },
|
||||
]);
|
||||
expect(() =>
|
||||
parseShippedBaselineExclusions("Shipped baseline exclusion: v2026.6.11 (8 PRs)."),
|
||||
).toThrow("malformed shipped baseline exclusion");
|
||||
expect(() =>
|
||||
parseShippedBaselineExclusions("Shipped baseline exclusions: v2026.6.11 (2 PRs: #101)."),
|
||||
).toThrow("invalid shipped baseline exclusion count");
|
||||
});
|
||||
|
||||
it("rejects tag/version drift and legacy oversized ledger anchors", () => {
|
||||
expect(() =>
|
||||
renderGithubReleaseNotes({
|
||||
changelog: changelogFor("- **PR #123** fix: example."),
|
||||
version,
|
||||
tag: "v2026.7.2-beta.1",
|
||||
repository,
|
||||
}),
|
||||
).toThrow("requires CHANGELOG.md version 2026.7.2");
|
||||
|
||||
expect(() =>
|
||||
renderGithubReleaseNotes({
|
||||
changelog: changelogFor(
|
||||
`### Complete contribution ledger\n\n${"legacy ".repeat(20_000)}`,
|
||||
).replace("### Complete contribution record\n\n", ""),
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
}),
|
||||
).toThrow("cannot be compacted without a complete contribution record");
|
||||
});
|
||||
|
||||
it("permits the Unreleased fallback only for alpha tags", () => {
|
||||
const changelog = changelogFor("- **PR #123** fix: example.").replace(
|
||||
"## 2026.7.1",
|
||||
"## Unreleased",
|
||||
);
|
||||
const rendered = renderGithubReleaseNotes({
|
||||
changelog,
|
||||
version,
|
||||
tag: "v2026.7.1-alpha.1",
|
||||
repository,
|
||||
});
|
||||
|
||||
expect(rendered.body).toContain("## 2026.7.1");
|
||||
expect(rendered.body).not.toContain("## Unreleased");
|
||||
expect(() =>
|
||||
renderGithubReleaseNotes({
|
||||
changelog,
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
}),
|
||||
).toThrow("CHANGELOG.md does not contain ## 2026.7.1");
|
||||
expect(() =>
|
||||
renderGithubReleaseNotes({
|
||||
changelog,
|
||||
version: "foo",
|
||||
tag: "vfoo-alpha.1",
|
||||
repository,
|
||||
}),
|
||||
).toThrow("invalid release tag");
|
||||
});
|
||||
|
||||
it("ignores fenced pseudo-headings and handles a release heading at EOF", () => {
|
||||
const fenced = [
|
||||
`## ${version}`,
|
||||
"",
|
||||
"```md",
|
||||
"## 2099.1.1",
|
||||
"```",
|
||||
"",
|
||||
"### Fixes",
|
||||
"",
|
||||
"- Still in the current release.",
|
||||
"",
|
||||
"## 2026.6.11",
|
||||
].join("\n");
|
||||
|
||||
expect(extractChangelogSection(fenced, version)).toContain("- Still in the current release.");
|
||||
expect(extractChangelogSection(`## ${version}`, version)).toBe(`## ${version}`);
|
||||
});
|
||||
|
||||
it("compacts at the real contribution record instead of a fenced pseudo-heading", () => {
|
||||
const changelog = changelogFor(`- **PR #123** ${"record-only-detail ".repeat(9_000)}`).replace(
|
||||
"### Fixes",
|
||||
["```md", "### Complete contribution record", "```", "", "### Fixes"].join("\n"),
|
||||
);
|
||||
const rendered = renderGithubReleaseNotes({ changelog, version, tag, repository });
|
||||
|
||||
expect(rendered.mode).toBe("compact");
|
||||
expect(rendered.body).toContain("```md\n### Complete contribution record\n```");
|
||||
expect(rendered.body).toContain("- A grouped user-facing fix.");
|
||||
expect(rendered.body).not.toContain("record-only-detail");
|
||||
});
|
||||
|
||||
it("verifies the exact generated compact body and optional proof tail", () => {
|
||||
const changelog = changelogFor(`- **PR #123** ${"z".repeat(130_000)}`);
|
||||
const verification = "### Release verification\n\n- release SHA: `abc123`";
|
||||
const rendered = renderGithubReleaseNotes({
|
||||
changelog,
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
verification,
|
||||
});
|
||||
|
||||
expect(
|
||||
verifyGithubReleaseNotes({
|
||||
body: rendered.body,
|
||||
changelog,
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
}),
|
||||
).toMatchObject({ matches: true, mode: "compact" });
|
||||
expect(
|
||||
verifyGithubReleaseNotes({
|
||||
body: rendered.body.replace("tag-pinned", "mutable"),
|
||||
changelog,
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
}).matches,
|
||||
).toBe(false);
|
||||
expect(
|
||||
verifyGithubReleaseNotes({
|
||||
body: rendered.body,
|
||||
changelog,
|
||||
version,
|
||||
tag: "v2026.7.1-beta.2",
|
||||
repository,
|
||||
}).matches,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat fenced verification headings as appended proof", () => {
|
||||
const changelog = changelogFor(
|
||||
[
|
||||
"```md",
|
||||
"### Release verification",
|
||||
"",
|
||||
"- Example only.",
|
||||
"```",
|
||||
"",
|
||||
"- **PR #123** fix: example.",
|
||||
].join("\n"),
|
||||
);
|
||||
const rendered = renderGithubReleaseNotes({ changelog, version, tag, repository });
|
||||
|
||||
expect(
|
||||
verifyGithubReleaseNotes({
|
||||
body: rendered.body,
|
||||
changelog,
|
||||
version,
|
||||
tag,
|
||||
repository,
|
||||
}),
|
||||
).toMatchObject({ matches: true, verificationIncluded: false });
|
||||
});
|
||||
});
|
||||
250
test/scripts/verify-release-notes.test.ts
Normal file
250
test/scripts/verify-release-notes.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
contaminatingPullRequestReferences,
|
||||
countTopLevelSectionBullets,
|
||||
cumulativeShippedPullRequests,
|
||||
highlightCountError,
|
||||
releaseNoteReferences,
|
||||
subtractShippedPullRequests,
|
||||
} from "../../.agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs";
|
||||
|
||||
const verifier = resolve(
|
||||
".agents/skills/openclaw-changelog-update/scripts/verify-release-notes.mjs",
|
||||
);
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "OpenClaw Test",
|
||||
GIT_AUTHOR_EMAIL: "test@openclaw.invalid",
|
||||
GIT_COMMITTER_NAME: "OpenClaw Test",
|
||||
GIT_COMMITTER_EMAIL: "test@openclaw.invalid",
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe("release-note verification", () => {
|
||||
it("counts only top-level Highlights bullets and enforces the 5-8 policy input", () => {
|
||||
const highlights = [
|
||||
"### Highlights",
|
||||
"",
|
||||
"- One",
|
||||
" - nested detail",
|
||||
"- Two",
|
||||
"- Three",
|
||||
"- Four",
|
||||
"- Five",
|
||||
"",
|
||||
"### Changes",
|
||||
"",
|
||||
"- Not a highlight",
|
||||
].join("\n");
|
||||
const overLimit = highlights.replace("- Five", "- Five\n- Six\n- Seven\n- Eight\n- Nine");
|
||||
|
||||
expect(countTopLevelSectionBullets(highlights, "Highlights")).toBe(5);
|
||||
expect(countTopLevelSectionBullets(overLimit, "Highlights")).toBe(9);
|
||||
expect(highlightCountError(highlights)).toBeUndefined();
|
||||
expect(highlightCountError(overLimit)).toBe(
|
||||
"### Highlights must contain 5-8 top-level bullets; found 9",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects prior-release PRs from prose or the existing record unless explicitly seeded", () => {
|
||||
const nodes = new Map([
|
||||
[97118, { __typename: "PullRequest" }],
|
||||
[102000, { __typename: "PullRequest" }],
|
||||
[98565, { __typename: "Issue" }],
|
||||
]);
|
||||
const params = {
|
||||
noteReferences: [97118, 98565],
|
||||
recordedReferences: [97118, 102000],
|
||||
sourcePullRequests: new Set([102000]),
|
||||
sourceReferences: [102000, 98565],
|
||||
seededPullRequests: new Set<number>(),
|
||||
nodes,
|
||||
};
|
||||
|
||||
expect(contaminatingPullRequestReferences(params)).toEqual([97118]);
|
||||
expect(
|
||||
contaminatingPullRequestReferences({
|
||||
...params,
|
||||
seededPullRequests: new Set([97118]),
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes Unreleased records from a cumulative shipped tag boundary", () => {
|
||||
const changelog = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## Unreleased",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`This audited record covers the complete base..${"a".repeat(40)} history: 1 merged PR.`,
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #1** fix: not shipped.",
|
||||
"",
|
||||
"## 2026.6.11",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
"This audited record covers the complete base..HEAD history: 0 merged PRs.",
|
||||
"",
|
||||
"#### Pull requests",
|
||||
"",
|
||||
"- **PR #2** fix: shipped.",
|
||||
].join("\n");
|
||||
|
||||
expect([...cumulativeShippedPullRequests(changelog, "test baseline")]).toEqual([2]);
|
||||
});
|
||||
|
||||
it("subtracts cumulative shipped PRs deterministically from the source inventory", () => {
|
||||
const source = {
|
||||
pullRequests: new Set([1, 2, 3]),
|
||||
references: [1, 2, 4],
|
||||
};
|
||||
|
||||
const result = subtractShippedPullRequests(source, [
|
||||
{ ref: "v2026.6.11", pullRequests: new Set([1, 2]) },
|
||||
{ ref: "v2026.6.10", pullRequests: new Set([2, 4]) },
|
||||
]);
|
||||
|
||||
expect([...source.pullRequests]).toEqual([3]);
|
||||
expect(source.references).toEqual([]);
|
||||
expect(result.baselines).toEqual([
|
||||
{ ref: "v2026.6.10", count: 2, pullRequests: [2, 4] },
|
||||
{ ref: "v2026.6.11", count: 1, pullRequests: [1] },
|
||||
]);
|
||||
expect([...result.pullRequests].toSorted((a, b) => a - b)).toEqual([1, 2, 4]);
|
||||
});
|
||||
|
||||
it("does not treat the shipped baseline inventory as current release-note references", () => {
|
||||
const baselines = [{ ref: "v2026.6.11", count: 2, pullRequests: [1, 2] }];
|
||||
const section = [
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"- Fixes #1 in the current range.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
"Shipped baseline exclusions: v2026.6.11 (2 PRs: #1, #2).",
|
||||
"",
|
||||
"- **PR #3** fix: current work.",
|
||||
].join("\n");
|
||||
|
||||
expect(releaseNoteReferences(section, baselines)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("records a canonical target SHA when --target is symbolic", () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-"));
|
||||
try {
|
||||
git(cwd, ["init", "-q"]);
|
||||
writeFileSync(
|
||||
join(cwd, "CHANGELOG.md"),
|
||||
[
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- One.",
|
||||
"- Two.",
|
||||
"- Three.",
|
||||
"- Four.",
|
||||
"- Five.",
|
||||
"",
|
||||
"### Changes",
|
||||
"",
|
||||
"### Fixes",
|
||||
].join("\n"),
|
||||
);
|
||||
git(cwd, ["add", "CHANGELOG.md"]);
|
||||
git(cwd, ["commit", "-qm", "initial"]);
|
||||
const targetSha = git(cwd, ["rev-parse", "HEAD"]);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
verifier,
|
||||
"--base",
|
||||
"HEAD",
|
||||
"--target",
|
||||
"HEAD",
|
||||
"--version",
|
||||
"2026.7.1",
|
||||
"--write-ledger",
|
||||
"--json",
|
||||
],
|
||||
{ cwd, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout).target).toBe(targetSha);
|
||||
expect(readFileSync(join(cwd, "CHANGELOG.md"), "utf8")).toContain(
|
||||
`This audited record covers the complete HEAD..${targetSha} history:`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a release base that is not an ancestor of the target", () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "openclaw-release-notes-"));
|
||||
try {
|
||||
git(cwd, ["init", "-q"]);
|
||||
writeFileSync(
|
||||
join(cwd, "CHANGELOG.md"),
|
||||
[
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 2026.7.1",
|
||||
"",
|
||||
"### Highlights",
|
||||
"",
|
||||
"- Test release.",
|
||||
"",
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
git(cwd, ["add", "CHANGELOG.md"]);
|
||||
git(cwd, ["commit", "-qm", "initial"]);
|
||||
git(cwd, ["branch", "target"]);
|
||||
|
||||
writeFileSync(join(cwd, "base.txt"), "base\n");
|
||||
git(cwd, ["add", "base.txt"]);
|
||||
git(cwd, ["commit", "-qm", "base"]);
|
||||
git(cwd, ["tag", "base-ref"]);
|
||||
|
||||
git(cwd, ["checkout", "-q", "target"]);
|
||||
writeFileSync(join(cwd, "target.txt"), "target\n");
|
||||
git(cwd, ["add", "target.txt"]);
|
||||
git(cwd, ["commit", "-qm", "target"]);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[verifier, "--base", "base-ref", "--target", "HEAD", "--version", "2026.7.1"],
|
||||
{ cwd, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
"release range base base-ref must be an ancestor of target HEAD",
|
||||
);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user