From 3ac20744a922c947a73b9c3a24281cf237077e70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:10:05 +0000 Subject: [PATCH 1/2] fix(release): harden release agent, guard PR body, and add missing test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs fixed in release.agent.js: - Regex escape (getMergedPRs): \d+ in regex literal matched literal backslash; PR numbers were silently empty on every run. - createReleasePR body: both shell and MCP providers generated a plain prose body. main-branch-guard requires three specific sections, so every automated release PR was blocked. Added buildReleasePRBody() helper used by both providers. - createReleasePR shell injection: backticks in the PR body caused command substitution when interpolated into the shell command. Now writes body to a temp file and uses --body-file instead. - Husky v9 compatibility: npx husky run pre-commit is v8 syntax. Correct call for v9 is npx lint-staged directly. - Hardcoded /tmp/ path: release-notes temp file now uses os.tmpdir(). - Stale comment: inline comment still referred to Husky after fix. Test coverage added / rewritten: - release.agent.test.js: rewrites to use subprocess ESM pattern - changelogUtils.test.js: new file, full coverage of parsing/validation - validate-changelog.test.js: replaced stub with real CLI and integration tests - validate-main-branch-pr.test.js: new file, full coverage of guard logic Issue template clarification (18-release.md): - Adds explicit develop β†’ release/vX.Y.Z β†’ main flow comment - Checklist now specifies that release branch is created FROM develop and the PR targets main Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01LNVr8xNwtAwfWVpaxW75Aq --- .github/ISSUE_TEMPLATE/18-release.md | 37 +- .../agents/__tests__/release.agent.test.js | 683 ++++++++++-------- .../includes/__tests__/changelogUtils.test.js | 334 +++++++++ scripts/agents/release.agent.js | 64 +- .../__tests__/validate-changelog.test.js | 229 +++++- .../__tests__/validate-main-branch-pr.test.js | 266 +++++++ 6 files changed, 1281 insertions(+), 332 deletions(-) create mode 100644 scripts/agents/includes/__tests__/changelogUtils.test.js create mode 100644 scripts/workflows/branch-policy/__tests__/validate-main-branch-pr.test.js diff --git a/.github/ISSUE_TEMPLATE/18-release.md b/.github/ISSUE_TEMPLATE/18-release.md index f5ef2aa38..900081a40 100644 --- a/.github/ISSUE_TEMPLATE/18-release.md +++ b/.github/ISSUE_TEMPLATE/18-release.md @@ -2,32 +2,43 @@ file_type: "issue-template" name: "πŸš€ Release" about: "Propose or track release management, versioning, or deployment tasks." -version: "1.0.2" +version: "1.1.0" last_updated: "2026-06-19" category: "github-templates" --- ## Release Summary - + + +## Full Changelog Entry Summary + + ## Milestones / Checklist - + -- [ ] Release goal described -- [ ] Versions/tags mapped -- [ ] Docs/changelog prepared -- [ ] Release notes drafted -- [ ] QA/staging verified +- [ ] All in-flight PRs confirmed merged to `develop` +- [ ] `CHANGELOG.md` unreleased entries reviewed and finalised +- [ ] `[X.Y.Z]` section cut in `CHANGELOG.md` with release date +- [ ] Release branch `release/vX.Y.Z` created **from** `develop` +- [ ] `release/vX.Y.Z` PR opened **against** `main` using `pr_release.md` template +- [ ] CI green on release PR +- [ ] Release PR reviewed and approved +- [ ] Release PR merged β†’ `main`; tag `vX.Y.Z` created ## Acceptance Criteria - [ ] Release completed and verified -- [ ] Documentation/changelog updated -- [ ] Release notes published +- [ ] `CHANGELOG.md` section published with full entry list +- [ ] Release tag `vX.Y.Z` exists on `main` +- [ ] GitHub Release published with compiled notes +- [ ] `develop` synced with `main` after merge (no drift) ## Additional Context @@ -39,12 +50,14 @@ category: "github-templates" - [ ] Release goal and scope defined - [ ] Milestones and checklist mapped -- [ ] Estimate added +- [ ] All changelog entries catalogued +- [ ] Open PRs identified for pre-tag merge ## Definition of Done (DoD) - [ ] All checklist and acceptance criteria completed -- [ ] Documentation/changelog updated +- [ ] CHANGELOG section published +- [ ] Release tag on `main` - [ ] Approved by maintainer --- diff --git a/scripts/agents/__tests__/release.agent.test.js b/scripts/agents/__tests__/release.agent.test.js index 7a6a5445a..59fd23264 100644 --- a/scripts/agents/__tests__/release.agent.test.js +++ b/scripts/agents/__tests__/release.agent.test.js @@ -1,356 +1,443 @@ /** - * Jest suite verifying the baseline behaviour of `release.agent.js`. + * Jest suite for release.agent.js. + * + * release.agent.js is a native ESM module that uses import.meta, so it cannot + * be directly require()'d or statically imported through babel-jest. All tests + * that exercise the module's exported functions run the code as a Node.js ESM + * child process (the same pattern used in release.agent.mcp.test.js) and parse + * the JSON result from stdout. File-system–dependent tests write temp fixtures + * and pass the paths into the subprocess. + * * @see ../release.agent.js */ -import { jest } from "@jest/globals"; - -const execSync = jest.fn(() => ""); -const fs = { - existsSync: jest.fn(() => true), - readFileSync: jest.fn(), - writeFileSync: jest.fn(), -}; - -describe("Release Agent", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("Version Determination", () => { - test("should correctly bump patch version", () => { - const currentVersion = "1.2.3"; - const scope = "patch"; - - // Test version bumping logic - const parts = currentVersion.split(".").map(Number); - parts[2] += 1; - const expected = parts.join("."); - - expect(expected).toBe("1.2.4"); - }); - - test("should correctly bump minor version and reset patch", () => { - const currentVersion = "1.2.3"; - const scope = "minor"; - - const parts = currentVersion.split(".").map(Number); - parts[1] += 1; - parts[2] = 0; - const expected = parts.join("."); - - expect(expected).toBe("1.3.0"); - }); - - test("should correctly bump major version and reset minor/patch", () => { - const currentVersion = "1.2.3"; - const scope = "major"; - - const parts = currentVersion.split(".").map(Number); - parts[0] += 1; - parts[1] = 0; - parts[2] = 0; - const expected = parts.join("."); - - expect(expected).toBe("2.0.0"); - }); - - test("should handle versions with leading zeros", () => { - const currentVersion = "0.1.0"; - const scope = "minor"; - - const parts = currentVersion.split(".").map(Number); - parts[1] += 1; - parts[2] = 0; - const expected = parts.join("."); - - expect(expected).toBe("0.2.0"); - }); - }); - - describe("Version Validation", () => { - test("should accept valid semantic versions", () => { - const validVersions = ["1.0.0", "1.2.3", "0.1.0", "10.20.30"]; - - validVersions.forEach((version) => { - const parts = version.split("."); - expect(parts).toHaveLength(3); - expect(parts.every((p) => !isNaN(parseInt(p)))).toBe(true); - }); - }); - - test("should reject invalid version formats", () => { - const invalidVersions = ["1.0", "1.0.0.0", "v1.0", "1.0.x", "abc"]; - - invalidVersions.forEach((version) => { - const parts = version.replace(/^v/, "").split("."); - const isValid = - parts.length === 3 && parts.every((p) => !isNaN(parseInt(p))); - expect(isValid).toBe(false); - }); - }); - }); - - describe("Changelog Validation", () => { - test("should detect unreleased section in changelog", () => { - const changelogContent = `# Changelog +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const repoRoot = process.cwd(); + +function runNodeEsm(code) { + const raw = execFileSync( + process.execPath, + ["--input-type=module", "-e", code], + { cwd: repoRoot, encoding: "utf8" }, + ).trim(); + const lines = raw.split("\n").filter(Boolean); + return lines[lines.length - 1] || ""; +} + +function writeTempChangelog(content) { + const file = path.join( + os.tmpdir(), + `ra-test-${Date.now()}-${Math.random().toString(36).slice(2)}.md`, + ); + fs.writeFileSync(file, content, "utf8"); + return file; +} + +const MINIMAL_CHANGELOG = `# Changelog ## [Unreleased] ### Added -- New feature - -### Fixed -/** - * @jest-environment jsdom - */ -- Bug fix -## [1.0.0] - 2025-12-15 -`; - - expect(changelogContent).toContain("[Unreleased]"); - expect(changelogContent).toMatch(/## \[Unreleased\]/); - }); +- First unreleased feature - test("should validate changelog has entries under unreleased", () => { - const changelogContent = `# Changelog - -## [Unreleased] +## [0.1.0] - 2025-01-01 ### Added -- New feature -`; - const unreleasedSection = changelogContent - .split("## [Unreleased]")[1] - ?.split("## [")[0]; - - expect(unreleasedSection).toBeDefined(); - expect(unreleasedSection).toContain("### Added"); - expect(unreleasedSection).toContain("- New feature"); - }); - - test("should reject changelog without unreleased section", () => { - const changelogContent = `# Changelog - -## [1.0.0] - 2025-12-15 - -### Added - Initial release `; - expect(changelogContent).not.toContain("[Unreleased]"); - }); - - test("should detect empty unreleased section", () => { - const changelogContent = `# Changelog - -## [Unreleased] - -## [1.0.0] - 2025-12-15 -`; - - const unreleasedSection = changelogContent - .split("## [Unreleased]")[1] - ?.split("## [")[0] - .trim(); - - expect(unreleasedSection).toBe(""); - }); +// --------------------------------------------------------------------------- +// determineNextVersion +// --------------------------------------------------------------------------- + +describe("determineNextVersion", () => { + function nextVersion(current, scope) { + return JSON.parse( + runNodeEsm(` + const { determineNextVersion } = await import('./scripts/agents/release.agent.js'); + console.log(JSON.stringify(determineNextVersion(${JSON.stringify(current)}, ${JSON.stringify(scope)}))); + `), + ); + } + + test("bumps patch", () => + expect(nextVersion("1.2.3", "patch")).toBe("1.2.4")); + test("bumps minor and resets patch", () => + expect(nextVersion("1.2.3", "minor")).toBe("1.3.0")); + test("bumps major and resets minor + patch", () => + expect(nextVersion("1.2.3", "major")).toBe("2.0.0")); + test("defaults to patch when scope omitted", () => + expect(nextVersion("0.5.0", undefined)).toBe("0.5.1")); + test("handles 0.x versions", () => + expect(nextVersion("0.1.0", "minor")).toBe("0.2.0")); + + test("throws on invalid current version", () => { + expect(() => + runNodeEsm(` + const { determineNextVersion } = await import('./scripts/agents/release.agent.js'); + determineNextVersion('not-a-version', 'patch'); + `), + ).toThrow(); }); +}); - describe("Dry Run Mode", () => { - test("should not execute commands in dry run mode", () => { - const dryRun = true; - const command = "git tag v1.0.0"; - - if (!dryRun) { - execSync(command); - } - - expect(execSync).not.toHaveBeenCalled(); - }); - - test("should log commands without executing in dry run", () => { - const consoleSpy = jest.spyOn(console, "log").mockImplementation(); - const dryRun = true; - const command = "git push origin v1.0.0"; - - if (dryRun) { - console.log(`[DRY-RUN] Would execute: ${command}`); - } else { - execSync(command); - } +// --------------------------------------------------------------------------- +// compareVersions +// --------------------------------------------------------------------------- + +describe("compareVersions", () => { + function compare(a, b) { + return JSON.parse( + runNodeEsm(` + const { compareVersions } = await import('./scripts/agents/release.agent.js'); + console.log(JSON.stringify(compareVersions(${JSON.stringify(a)}, ${JSON.stringify(b)}))); + `), + ); + } + + test("returns 0 for equal", () => expect(compare("1.2.3", "1.2.3")).toBe(0)); + test("returns 1 when left greater", () => + expect(compare("2.0.0", "1.9.9")).toBe(1)); + test("returns -1 when left lesser", () => + expect(compare("1.0.0", "2.0.0")).toBe(-1)); + test("compares minor correctly", () => + expect(compare("1.3.0", "1.2.9")).toBe(1)); + test("compares patch correctly", () => + expect(compare("1.2.3", "1.2.4")).toBe(-1)); +}); - expect(consoleSpy).toHaveBeenCalledWith( - `[DRY-RUN] Would execute: ${command}`, - ); - expect(execSync).not.toHaveBeenCalled(); +// --------------------------------------------------------------------------- +// isValidGitRef +// --------------------------------------------------------------------------- + +describe("isValidGitRef", () => { + function valid(ref) { + return JSON.parse( + runNodeEsm(` + const { isValidGitRef } = await import('./scripts/agents/release.agent.js'); + console.log(JSON.stringify(isValidGitRef(${JSON.stringify(ref)}))); + `), + ); + } + + test("accepts version tags", () => expect(valid("v1.2.3")).toBe(true)); + test("accepts branch names", () => expect(valid("develop")).toBe(true)); + test("accepts release branch paths", () => + expect(valid("release/v1.2.3")).toBe(true)); + test("accepts HEAD", () => expect(valid("HEAD")).toBe(true)); + test("accepts short SHAs", () => expect(valid("abc1234")).toBe(true)); + test("rejects refs with whitespace", () => + expect(valid("my branch")).toBe(false)); + test("rejects refs starting with -", () => expect(valid("-bad")).toBe(false)); + test("rejects caret operator", () => expect(valid("HEAD^")).toBe(false)); + test("rejects tilde operator", () => expect(valid("v1.0.0~1")).toBe(false)); + test("rejects empty string", () => expect(valid("")).toBe(false)); + test("rejects null", () => expect(valid(null)).toBe(false)); +}); - consoleSpy.mockRestore(); - }); +// --------------------------------------------------------------------------- +// buildReleasePRBody β€” must satisfy main-branch-guard required sections +// --------------------------------------------------------------------------- + +describe("buildReleasePRBody", () => { + function getBody(version) { + return JSON.parse( + runNodeEsm(` + const { buildReleasePRBody } = await import('./scripts/agents/release.agent.js'); + console.log(JSON.stringify(buildReleasePRBody(${JSON.stringify(version)}))); + `), + ); + } + + test("contains ## Linked issues & merged PRs section", () => { + expect(getBody("1.2.3")).toMatch(/^## Linked issues\s*&\s*merged PRs$/im); }); - describe("Git Tag Creation", () => { - test("should format git tag command correctly", () => { - const version = "1.2.3"; - const message = "Release v1.2.3"; - const tagCommand = `git tag -a v${version} -m "${message}"`; - - expect(tagCommand).toBe('git tag -a v1.2.3 -m "Release v1.2.3"'); - }); + test("contains ## Changelog section", () => { + expect(getBody("1.2.3")).toMatch(/^## Changelog$/im); + }); - test("should include changelog in tag message", () => { - const version = "1.2.3"; - const changelog = "### Added\\n- New feature"; - const tagMessage = `Release v${version}\\n\\n${changelog}`; + test("contains ### Checklist (Global DoD / PR) section", () => { + expect(getBody("1.2.3")).toMatch( + /^### Checklist\s+\(Global DoD\s*\/\s*PR\)$/im, + ); + }); - expect(tagMessage).toContain("Release v1.2.3"); - expect(tagMessage).toContain("### Added"); - expect(tagMessage).toContain("- New feature"); - }); + test("embeds the version number", () => { + expect(getBody("0.6.0")).toContain("0.6.0"); }); - describe("Release PR Creation", () => { - test("should format release branch name correctly", () => { - const version = "1.2.3"; - const branchName = `release/v${version}`; + test("documents develop as origin branch", () => { + expect(getBody("1.0.0")).toContain("develop"); + }); - expect(branchName).toBe("release/v1.2.3"); - }); + test("includes today's ISO date", () => { + const today = new Date().toISOString().split("T")[0]; + expect(getBody("1.0.0")).toContain(today); + }); +}); - test("should create PR from release branch to main", () => { - const version = "1.2.3"; - const baseBranch = "main"; - const headBranch = `release/v${version}`; +// --------------------------------------------------------------------------- +// detectBreakingChanges +// --------------------------------------------------------------------------- + +describe("detectBreakingChanges", () => { + function detect(releases, version) { + return JSON.parse( + runNodeEsm(` + const { detectBreakingChanges } = await import('./scripts/agents/release.agent.js'); + console.log(JSON.stringify(detectBreakingChanges({ releases: ${JSON.stringify(releases)} }, ${JSON.stringify(version)}))); + `), + ); + } + + test("detects 'breaking' keyword in changed section", () => { + const releases = [ + { + version: "2.0.0", + sections: { changed: ["Breaking: old API removed"] }, + }, + ]; + expect(detect(releases, "2.0.0").length).toBeGreaterThan(0); + }); - const prCommand = `gh pr create --base ${baseBranch} --head ${headBranch} --title "Release v${version}"`; + test("flags removed section items as breaking", () => { + const releases = [ + { version: "2.0.0", sections: { removed: ["Legacy plugin support"] } }, + ]; + const result = detect(releases, "2.0.0"); + expect(result.some((bc) => bc.section === "removed")).toBe(true); + }); - expect(prCommand).toContain("--base main"); - expect(prCommand).toContain("--head release/v1.2.3"); - expect(prCommand).toContain('--title "Release v1.2.3"'); - }); + test("returns empty array for non-breaking release", () => { + const releases = [ + { version: "1.0.0", sections: { added: ["New feature"] } }, + ]; + expect(detect(releases, "1.0.0")).toHaveLength(0); }); - describe("Error Handling", () => { - test("should throw error for missing changelog", () => { - fs.existsSync.mockReturnValue(false); + test("returns empty array for unknown version", () => { + const releases = [ + { version: "1.0.0", sections: { added: ["New feature"] } }, + ]; + expect(detect(releases, "9.9.9")).toHaveLength(0); + }); +}); - const checkChangelog = () => { - if (!fs.existsSync("CHANGELOG.md")) { - throw new Error("CHANGELOG.md not found"); - } - }; +// --------------------------------------------------------------------------- +// generateHighlights +// --------------------------------------------------------------------------- + +describe("generateHighlights", () => { + function highlights(releases, version) { + return JSON.parse( + runNodeEsm(` + const { generateHighlights } = await import('./scripts/agents/release.agent.js'); + console.log(JSON.stringify(generateHighlights({ releases: ${JSON.stringify(releases)} }, ${JSON.stringify(version)}))); + `), + ); + } + + test("caps highlights at 5", () => { + const releases = [ + { + version: "1.0.0", + sections: { + added: ["A", "B", "C", "D"], + changed: ["E"], + security: ["F"], + }, + }, + ]; + expect(highlights(releases, "1.0.0").length).toBeLessThanOrEqual(5); + }); - expect(checkChangelog).toThrow("CHANGELOG.md not found"); - }); + test("prioritises added section", () => { + const releases = [{ version: "1.0.0", sections: { added: ["Feature A"] } }]; + const result = highlights(releases, "1.0.0"); + expect(result.some((h) => h.section === "added")).toBe(true); + }); - test("should throw error for invalid version format", () => { - const invalidVersion = "1.0.invalid"; + test("returns empty array for unknown version", () => { + const releases = [{ version: "1.0.0", sections: { added: ["x"] } }]; + expect(highlights(releases, "9.9.9")).toHaveLength(0); + }); +}); - const validateVersion = (version) => { - const semverRegex = /^\d+\.\d+\.\d+$/; - if (!semverRegex.test(version)) { - throw new Error(`Invalid version format: ${version}`); - } - }; +// --------------------------------------------------------------------------- +// updateChangelog (file system) +// --------------------------------------------------------------------------- + +describe("updateChangelog", () => { + test("rolls [Unreleased] to versioned section", () => { + const tmpFile = writeTempChangelog(MINIMAL_CHANGELOG); + try { + runNodeEsm(` + const { updateChangelog } = await import('./scripts/agents/release.agent.js'); + updateChangelog('0.2.0', { changelogPath: ${JSON.stringify(tmpFile)} }); + console.log('done'); + `); + const content = fs.readFileSync(tmpFile, "utf8"); + expect(content).toContain("## [0.2.0]"); + expect(content).toContain("## [Unreleased]"); + } finally { + if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); + } + }); - expect(() => validateVersion(invalidVersion)).toThrow( - "Invalid version format", + test("new [Unreleased] appears before versioned section", () => { + const tmpFile = writeTempChangelog(MINIMAL_CHANGELOG); + try { + runNodeEsm(` + const { updateChangelog } = await import('./scripts/agents/release.agent.js'); + updateChangelog('0.2.0', { changelogPath: ${JSON.stringify(tmpFile)} }); + console.log('done'); + `); + const content = fs.readFileSync(tmpFile, "utf8"); + expect(content.indexOf("## [Unreleased]")).toBeLessThan( + content.indexOf("## [0.2.0]"), ); - }); - - test("should handle git command failures gracefully", () => { - execSync.mockImplementation(() => { - throw new Error("fatal: not a git repository"); - }); - - const executeGitCommand = (allowError = false) => { - try { - execSync("git status"); - } catch (error) { - if (!allowError) { - throw error; - } - return null; - } - }; - - expect(() => executeGitCommand(false)).toThrow( - "fatal: not a git repository", - ); - expect(executeGitCommand(true)).toBeNull(); - }); + } finally { + if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); + } }); - describe("Changelog Update", () => { - test("should move unreleased entries to versioned section", () => { - const oldChangelog = `## [Unreleased] - -### Added -- New feature - -## [1.0.0] - 2025-12-01`; - - const newVersion = "1.0.1"; - const date = "2025-12-15"; + test("dry-run returns updated content without writing", () => { + const tmpFile = writeTempChangelog(MINIMAL_CHANGELOG); + try { + const result = JSON.parse( + runNodeEsm(` + const { updateChangelog } = await import('./scripts/agents/release.agent.js'); + const updated = updateChangelog('0.2.0', { changelogPath: ${JSON.stringify(tmpFile)}, dryRun: true }); + console.log(JSON.stringify(updated)); + `), + ); + expect(result).toContain("## [0.2.0]"); + expect(fs.readFileSync(tmpFile, "utf8")).toBe(MINIMAL_CHANGELOG); + } finally { + if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); + } + }); +}); - const newChangelog = oldChangelog.replace( - "## [Unreleased]", - `## [Unreleased] +// --------------------------------------------------------------------------- +// validatePostReleaseChangelog +// --------------------------------------------------------------------------- -## [${newVersion}] - ${date}`, - ); +describe("validatePostReleaseChangelog", () => { + const VALID_POST_RELEASE = `# Changelog - expect(newChangelog).toContain(`## [${newVersion}] - ${date}`); - expect(newChangelog).toContain("## [Unreleased]"); - }); +## [Unreleased] - test("should preserve unreleased section for next cycle", () => { - const newChangelog = `## [Unreleased] +### Added -## [1.0.1] - 2025-12-15 +## [1.0.0] - 2025-06-01 ### Added -- New feature -## [1.0.0] - 2025-12-01`; +- Initial release +`; - const unreleasedSection = newChangelog - .split("## [Unreleased]")[1] - ?.split("## [1.0.1]")[0] - .trim(); + test("passes for a valid post-release changelog", () => { + const tmpFile = writeTempChangelog(VALID_POST_RELEASE); + try { + expect(() => + runNodeEsm(` + const { validatePostReleaseChangelog } = await import('./scripts/agents/release.agent.js'); + validatePostReleaseChangelog(${JSON.stringify(tmpFile)}, '1.0.0'); + console.log('ok'); + `), + ).not.toThrow(); + } finally { + if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); + } + }); - expect(newChangelog).toContain("## [Unreleased]"); - expect(unreleasedSection).toBe(""); - }); + test("throws when [Unreleased] section is missing", () => { + const content = `# Changelog\n\n## [1.0.0] - 2025-06-01\n\n### Added\n\n- Initial release\n`; + const tmpFile = writeTempChangelog(content); + try { + expect(() => + runNodeEsm(` + const { validatePostReleaseChangelog } = await import('./scripts/agents/release.agent.js'); + validatePostReleaseChangelog(${JSON.stringify(tmpFile)}, '1.0.0'); + `), + ).toThrow(); + } finally { + if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); + } }); - describe("Scope Detection", () => { - test("should detect scope from command line args", () => { - const args = ["--scope=major"]; - const scope = - args.find((arg) => arg.startsWith("--scope="))?.split("=")[1] || - "patch"; + test("throws when versioned section is missing", () => { + const content = `# Changelog\n\n## [Unreleased]\n\n### Added\n\n`; + const tmpFile = writeTempChangelog(content); + try { + expect(() => + runNodeEsm(` + const { validatePostReleaseChangelog } = await import('./scripts/agents/release.agent.js'); + validatePostReleaseChangelog(${JSON.stringify(tmpFile)}, '2.0.0'); + `), + ).toThrow(); + } finally { + if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); + } + }); +}); - expect(scope).toBe("major"); - }); +// --------------------------------------------------------------------------- +// getReleaseProvider +// --------------------------------------------------------------------------- + +describe("getReleaseProvider", () => { + function providerInfo(name) { + return JSON.parse( + runNodeEsm(` + const { getReleaseProvider } = await import('./scripts/agents/release.agent.js'); + const p = getReleaseProvider(${JSON.stringify(name)}); + console.log(JSON.stringify({ + name: p.name, + hasPreflight: typeof p.preflight === 'function', + hasCreateTag: typeof p.createTag === 'function', + hasPushChanges: typeof p.pushChanges === 'function', + hasCreateReleasePR: typeof p.createReleasePR === 'function', + hasCreateRelease: typeof p.createRelease === 'function', + })); + `), + ); + } + + test("returns shell provider with correct name", () => { + expect(providerInfo("shell").name).toBe("shell"); + }); - test("should default to patch when no scope provided", () => { - const args = ["--dry-run"]; - const scope = - args.find((arg) => arg.startsWith("--scope="))?.split("=")[1] || - "patch"; + test("returns mcp provider with correct name", () => { + expect(providerInfo("mcp").name).toBe("mcp"); + }); - expect(scope).toBe("patch"); - }); + test("shell provider exposes required interface", () => { + const info = providerInfo("shell"); + expect(info.hasPreflight).toBe(true); + expect(info.hasCreateTag).toBe(true); + expect(info.hasPushChanges).toBe(true); + expect(info.hasCreateReleasePR).toBe(true); + expect(info.hasCreateRelease).toBe(true); + }); - test("should handle all valid scope values", () => { - const validScopes = ["major", "minor", "patch"]; + test("mcp provider exposes required interface", () => { + const info = providerInfo("mcp"); + expect(info.hasPreflight).toBe(true); + expect(info.hasCreateTag).toBe(true); + expect(info.hasPushChanges).toBe(true); + expect(info.hasCreateReleasePR).toBe(true); + expect(info.hasCreateRelease).toBe(true); + }); - validScopes.forEach((scope) => { - expect(["major", "minor", "patch"]).toContain(scope); - }); - }); + test("throws for unknown provider", () => { + expect(() => providerInfo("unknown")).toThrow(); }); }); diff --git a/scripts/agents/includes/__tests__/changelogUtils.test.js b/scripts/agents/includes/__tests__/changelogUtils.test.js new file mode 100644 index 000000000..f7583b8d8 --- /dev/null +++ b/scripts/agents/includes/__tests__/changelogUtils.test.js @@ -0,0 +1,334 @@ +/** + * Jest suite for changelogUtils.cjs β€” covers parsing, validation, and + * unreleased-change detection against the Keep a Changelog format. + * @see ../changelogUtils.cjs + */ +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +const { + parseChangelog, + validateChangelog, + getLatestRelease, + getUnreleasedChanges, + hasUnreleasedChanges, +} = require(path.join(__dirname, "../changelogUtils.cjs")); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function writeTempChangelog(content) { + const file = path.join(os.tmpdir(), `cl-test-${Date.now()}.md`); + fs.writeFileSync(file, content, "utf8"); + return file; +} + +const FULL_CHANGELOG = `# Changelog + +## [Unreleased] + +### Added + +- Upcoming feature + +### Fixed + +- Minor bug fix + +## [1.2.0] - 2025-06-01 + +### Added + +- Feature X +- Feature Y + +### Changed + +- Improved performance + +## [1.1.0] - 2025-05-01 + +### Fixed + +- Critical bug + +## [1.0.0] - 2025-04-01 + +### Added + +- Initial release +`; + +const EMPTY_UNRELEASED_CHANGELOG = `# Changelog + +## [Unreleased] + +## [1.0.0] - 2025-04-01 + +### Added + +- Initial release +`; + +const NO_UNRELEASED_CHANGELOG = `# Changelog + +## [1.0.0] - 2025-04-01 + +### Added + +- Initial release +`; + +// --------------------------------------------------------------------------- +// parseChangelog +// --------------------------------------------------------------------------- + +describe("parseChangelog", () => { + test("parses all release headers", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + const versions = data.releases.map((r) => r.version); + expect(versions).toContain("Unreleased"); + expect(versions).toContain("1.2.0"); + expect(versions).toContain("1.1.0"); + expect(versions).toContain("1.0.0"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("extracts sections within a release", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + const release = data.releases.find((r) => r.version === "1.2.0"); + expect(release.sections).toHaveProperty("added"); + expect(release.sections.added).toContain("Feature X"); + expect(release.sections.added).toContain("Feature Y"); + expect(release.sections).toHaveProperty("changed"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("extracts Unreleased entries", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + const unreleased = data.releases.find((r) => r.version === "Unreleased"); + expect(unreleased.sections.added).toContain("Upcoming feature"); + expect(unreleased.sections.fixed).toContain("Minor bug fix"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("parses release dates", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + const release = data.releases.find((r) => r.version === "1.2.0"); + expect(release.date).toBe("2025-06-01"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("throws when file does not exist", () => { + expect(() => parseChangelog("/nonexistent/CHANGELOG.md")).toThrow( + /Changelog file not found/, + ); + }); + + test("returns format and semver metadata", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + expect(data.format).toBe("keepachangelog"); + expect(data.semver).toBe(true); + } finally { + fs.unlinkSync(tmpFile); + } + }); +}); + +// --------------------------------------------------------------------------- +// validateChangelog +// --------------------------------------------------------------------------- + +describe("validateChangelog", () => { + test("passes for a valid changelog", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + const result = validateChangelog(data); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("fails when releases array is missing", () => { + const result = validateChangelog({}); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch(/releases array/i); + }); + + test("fails when releases array is empty", () => { + const result = validateChangelog({ releases: [] }); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch(/at least one release/i); + }); + + test("fails for release missing a date", () => { + const result = validateChangelog({ + releases: [{ version: "1.0.0", sections: {} }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /missing date/i.test(e))).toBe(true); + }); + + test("fails for invalid date format", () => { + const result = validateChangelog({ + releases: [{ version: "1.0.0", date: "01/06/2025", sections: {} }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /invalid date/i.test(e))).toBe(true); + }); + + test("fails for unknown section name", () => { + const result = validateChangelog({ + releases: [ + { + version: "1.0.0", + date: "2025-06-01", + sections: { improvements: ["something"] }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /unknown section/i.test(e))).toBe(true); + }); + + test("passes for Unreleased entry without a date", () => { + const result = validateChangelog({ + releases: [ + { version: "Unreleased", sections: { added: ["WIP feature"] } }, + { version: "1.0.0", date: "2025-04-01", sections: {} }, + ], + }); + expect(result.valid).toBe(true); + }); + + test("fails for version not matching semver or Unreleased", () => { + const result = validateChangelog({ + releases: [{ version: "v1.0.0", date: "2025-06-01", sections: {} }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /invalid version/i.test(e))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// getLatestRelease +// --------------------------------------------------------------------------- + +describe("getLatestRelease", () => { + test("returns the first non-unreleased entry", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + const latest = getLatestRelease(data); + expect(latest.version).toBe("1.2.0"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("returns null when releases array is empty", () => { + expect(getLatestRelease({ releases: [] })).toBeNull(); + }); + + test("returns null when only Unreleased exists", () => { + const content = `# Changelog\n\n## [Unreleased]\n\n### Added\n\n- thing\n`; + const tmpFile = writeTempChangelog(content); + try { + const data = parseChangelog(tmpFile); + expect(getLatestRelease(data)).toBeNull(); + } finally { + fs.unlinkSync(tmpFile); + } + }); +}); + +// --------------------------------------------------------------------------- +// getUnreleasedChanges +// --------------------------------------------------------------------------- + +describe("getUnreleasedChanges", () => { + test("returns the Unreleased entry", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + const unreleased = getUnreleasedChanges(data); + expect(unreleased).not.toBeNull(); + expect(unreleased.version).toBe("Unreleased"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("returns null when no Unreleased entry exists", () => { + const tmpFile = writeTempChangelog(NO_UNRELEASED_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + expect(getUnreleasedChanges(data)).toBeNull(); + } finally { + fs.unlinkSync(tmpFile); + } + }); +}); + +// --------------------------------------------------------------------------- +// hasUnreleasedChanges +// --------------------------------------------------------------------------- + +describe("hasUnreleasedChanges", () => { + test("returns true when Unreleased section has entries", () => { + const tmpFile = writeTempChangelog(FULL_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + expect(hasUnreleasedChanges(data)).toBe(true); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("returns false when Unreleased section exists but is empty", () => { + const tmpFile = writeTempChangelog(EMPTY_UNRELEASED_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + expect(hasUnreleasedChanges(data)).toBe(false); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("returns false when no Unreleased section exists", () => { + const tmpFile = writeTempChangelog(NO_UNRELEASED_CHANGELOG); + try { + const data = parseChangelog(tmpFile); + expect(hasUnreleasedChanges(data)).toBe(false); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("returns false for empty releases array", () => { + expect(hasUnreleasedChanges({ releases: [] })).toBe(false); + }); +}); diff --git a/scripts/agents/release.agent.js b/scripts/agents/release.agent.js index d8af558ab..b48270941 100644 --- a/scripts/agents/release.agent.js +++ b/scripts/agents/release.agent.js @@ -17,6 +17,7 @@ */ import fs from "fs"; +import os from "os"; import path from "path"; import { execSync } from "child_process"; import { fileURLToPath } from "url"; @@ -289,7 +290,7 @@ function getMergedPRs(fromTag, toTag = "HEAD") { if (!gitLog) return []; - const prPattern = /Merge pull request #(\\d+) from (.+)/; + const prPattern = /Merge pull request #(\d+) from (.+)/; const prs = []; gitLog @@ -736,7 +737,7 @@ function createRelease(version, options = {}) { } // Use gh CLI to create release - const notesFile = `/tmp/release-notes-${version}.md`; + const notesFile = path.join(os.tmpdir(), `release-notes-${version}.md`); fs.writeFileSync(notesFile, releaseNotes, "utf8"); try { @@ -812,14 +813,39 @@ function validatePostReleaseChangelog( } } +/** + * Build a release PR body that satisfies the main-branch-guard required sections. + * @param {string} version - Release version (e.g. "1.2.3") + * @returns {string} PR body markdown + */ +function buildReleasePRBody(version) { + const today = new Date().toISOString().split("T")[0]; + return `## Linked issues & merged PRs + + + +## Changelog + +See \`CHANGELOG.md\` for the full [\`${version}\`] entry dated ${today}. + +### Checklist (Global DoD / PR) + +- [x] Release branch \`release/v${version}\` created from \`develop\` +- [x] \`VERSION\` bumped to \`${version}\` +- [x] \`CHANGELOG.md\` \`[Unreleased]\` rolled to \`[${version}] - ${today}\` +- [x] Release notes compiled +- [ ] CI checks green +- [ ] Approved by maintainer +`; +} + /** * Create release PR from release branch to main */ function createReleasePR(version, branch, options = {}) { const { dryRun = false } = options; const title = `chore(release): v${version}`; - const body = - "Automated release PR generated by release.agent.js. Includes version bump, changelog update, and tag creation."; + const body = buildReleasePRBody(version); if (dryRun) { console.log( @@ -828,11 +854,17 @@ function createReleasePR(version, branch, options = {}) { return; } - exec( - `gh pr create --base main --head ${branch} --title "${title}" --body "${body}"`, - dryRun, - ); - console.log("βœ“ Release PR created"); + const bodyFile = path.join(os.tmpdir(), `release-pr-body-${version}.md`); + fs.writeFileSync(bodyFile, body, "utf8"); + try { + exec( + `gh pr create --base main --head ${branch} --title "${title}" --body-file "${bodyFile}"`, + dryRun, + ); + console.log("βœ“ Release PR created"); + } finally { + if (fs.existsSync(bodyFile)) fs.unlinkSync(bodyFile); + } } /** @@ -951,8 +983,7 @@ function createMcpReleaseProvider() { const { dryRun = false } = options; const { owner, repo } = getRepositoryContext(); const title = `chore(release): v${version}`; - const body = - "Automated release PR generated by release.agent.js via MCP provider. Includes version bump, changelog update, and tag creation."; + const body = buildReleasePRBody(version); if (dryRun) { console.log( @@ -1155,14 +1186,14 @@ async function run() { } } - // Step 5: Stage all changes and run Husky pre-commit hooks, then commit + // Step 5: Stage all changes and run lint-staged, then commit if (!dryRun) { exec("git add VERSION CHANGELOG.md"); - exec("npx husky run pre-commit"); + exec("npx lint-staged"); exec(`git commit -m "chore(release): bump version to ${nextVersion}"`); } else { console.log( - `\n[DRY-RUN] Would stage VERSION and CHANGELOG.md, run Husky pre-commit hooks, and commit with message: "chore(release): bump version to ${nextVersion}"`, + `\n[DRY-RUN] Would stage VERSION and CHANGELOG.md, run lint-staged, and commit with message: "chore(release): bump version to ${nextVersion}"`, ); } @@ -1221,7 +1252,12 @@ export { pushChanges, createRelease, determineNextVersion, + compareVersions, + isValidGitRef, + detectBreakingChanges, + generateHighlights, formatReleaseNotes, + buildReleasePRBody, createReleasePR, createShellReleaseProvider, createMcpReleaseProvider, diff --git a/scripts/validation/__tests__/validate-changelog.test.js b/scripts/validation/__tests__/validate-changelog.test.js index 8c9f57108..2ae2a429f 100644 --- a/scripts/validation/__tests__/validate-changelog.test.js +++ b/scripts/validation/__tests__/validate-changelog.test.js @@ -1,16 +1,229 @@ /** - * @jest-environment jsdom + * Jest suite for validate-changelog.cjs β€” exercises the CLI behaviour and + * delegates to changelogUtils for parsing/validation assertions. + * @see ../validate-changelog.cjs */ -/** - * Jest suite verifying the baseline behaviour of `validate-changelog.js`. - * @see ../validate-changelog.js - */ -const fs = require("fs"); const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const { execFileSync } = require("child_process"); + +const scriptPath = path.join(__dirname, "../validate-changelog.cjs"); +const changelogUtilsPath = path.join( + __dirname, + "../../agents/includes/changelogUtils.cjs", +); + +const { parseChangelog, validateChangelog } = require(changelogUtilsPath); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function writeTempChangelog(content) { + const file = path.join(os.tmpdir(), `vc-test-${Date.now()}.md`); + fs.writeFileSync(file, content, "utf8"); + return file; +} + +function runScript(args = [], env = {}) { + try { + const stdout = execFileSync(process.execPath, [scriptPath, ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { code: 0, stdout }; + } catch (err) { + return { + code: err.status || 1, + stdout: err.stdout || "", + stderr: err.stderr || "", + }; + } +} + +const VALID_CHANGELOG = `# Changelog + +## [Unreleased] + +### Added + +- Upcoming feature + +## [1.0.0] - 2025-04-01 + +### Added + +- Initial release +`; + +const INVALID_CHANGELOG = `# Changelog + +## [bad-version-format] - not-a-date + +### UnknownSection + +- Something +`; + +// --------------------------------------------------------------------------- +// Script file presence +// --------------------------------------------------------------------------- describe("validate-changelog.cjs", () => { - it("exists and can be referenced by newer tooling", () => { - const scriptPath = path.join(__dirname, "../validate-changelog.cjs"); + test("script file exists", () => { expect(fs.existsSync(scriptPath)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// CLI β€” valid changelog +// --------------------------------------------------------------------------- + +describe("validate-changelog CLI (valid changelog)", () => { + let tmpFile; + + beforeAll(() => { + tmpFile = writeTempChangelog(VALID_CHANGELOG); + }); + + afterAll(() => { + if (tmpFile && fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + }); + + test("exits 0 for a valid changelog", () => { + const result = runScript([tmpFile]); + expect(result.code).toBe(0); + }); + + test("prints validation success message", () => { + const result = runScript([tmpFile]); + expect(result.stdout).toMatch(/βœ“ Changelog is valid/); + }); + + test("reports release count in output", () => { + const result = runScript([tmpFile]); + expect(result.stdout).toMatch(/2 release/); + }); +}); + +// --------------------------------------------------------------------------- +// CLI β€” invalid changelog +// --------------------------------------------------------------------------- + +describe("validate-changelog CLI (invalid changelog)", () => { + let tmpFile; + + beforeAll(() => { + tmpFile = writeTempChangelog(INVALID_CHANGELOG); + }); + + afterAll(() => { + if (tmpFile && fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + }); + + test("exits non-zero for an invalid changelog", () => { + const result = runScript([tmpFile]); + expect(result.code).not.toBe(0); + }); + + test("reports validation failure in output", () => { + const result = runScript([tmpFile]); + const output = result.stdout + result.stderr; + expect(output).toMatch(/validation failed/i); + }); +}); + +// --------------------------------------------------------------------------- +// CLI β€” missing file +// --------------------------------------------------------------------------- + +describe("validate-changelog CLI (missing file)", () => { + test("exits non-zero when file does not exist", () => { + const result = runScript(["/nonexistent/CHANGELOG.md"]); + expect(result.code).not.toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// parseChangelog integration β€” section extraction +// --------------------------------------------------------------------------- + +describe("parseChangelog section extraction", () => { + test("extracts all standard section types", () => { + const content = `# Changelog\n\n## [1.0.0] - 2025-06-01\n\n### Added\n\n- Added thing\n\n### Fixed\n\n- Fixed thing\n\n### Security\n\n- Security thing\n`; + const tmpFile = writeTempChangelog(content); + try { + const data = parseChangelog(tmpFile); + const release = data.releases.find((r) => r.version === "1.0.0"); + expect(release.sections.added).toContain("Added thing"); + expect(release.sections.fixed).toContain("Fixed thing"); + expect(release.sections.security).toContain("Security thing"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + test("ignores placeholder and empty list items", () => { + const content = `# Changelog\n\n## [1.0.0] - 2025-06-01\n\n### Added\n\n- [placeholder]\n- Real item\n`; + const tmpFile = writeTempChangelog(content); + try { + const data = parseChangelog(tmpFile); + const release = data.releases.find((r) => r.version === "1.0.0"); + expect(release.sections.added).not.toContain("[placeholder]"); + expect(release.sections.added).toContain("Real item"); + } finally { + fs.unlinkSync(tmpFile); + } + }); +}); + +// --------------------------------------------------------------------------- +// validateChangelog β€” all valid section names +// --------------------------------------------------------------------------- + +describe("validateChangelog β€” allowed section names", () => { + const allowedSections = [ + "added", + "changed", + "deprecated", + "removed", + "fixed", + "security", + "documentation", + "performance", + ]; + + allowedSections.forEach((section) => { + test(`accepts '${section}' section`, () => { + const result = validateChangelog({ + releases: [ + { + version: "1.0.0", + date: "2025-06-01", + sections: { [section]: ["An entry"] }, + }, + ], + }); + expect(result.valid).toBe(true); + }); + }); + + test("rejects unknown section name", () => { + const result = validateChangelog({ + releases: [ + { + version: "1.0.0", + date: "2025-06-01", + sections: { improvements: ["Something"] }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => /unknown section/i.test(e))).toBe(true); + }); +}); diff --git a/scripts/workflows/branch-policy/__tests__/validate-main-branch-pr.test.js b/scripts/workflows/branch-policy/__tests__/validate-main-branch-pr.test.js new file mode 100644 index 000000000..c07f793e5 --- /dev/null +++ b/scripts/workflows/branch-policy/__tests__/validate-main-branch-pr.test.js @@ -0,0 +1,266 @@ +/** + * Jest suite for validate-main-branch-pr.cjs β€” the main-branch-guard script + * that enforces release/* and hotfix/* branch naming and PR body shape. + * @see ../validate-main-branch-pr.cjs + */ +const path = require("path"); + +const { + normaliseBranchName, + extractReleaseVersion, + isReleaseBranch, + isHotfixBranch, + isAllowedBranch, + validatePullRequestMetadata, +} = require(path.join(__dirname, "../validate-main-branch-pr.cjs")); + +// --------------------------------------------------------------------------- +// normaliseBranchName +// --------------------------------------------------------------------------- + +describe("normaliseBranchName", () => { + test("strips refs/heads/ prefix", () => { + expect(normaliseBranchName("refs/heads/main")).toBe("main"); + }); + + test("leaves plain branch names unchanged", () => { + expect(normaliseBranchName("release/v1.2.3")).toBe("release/v1.2.3"); + }); + + test("trims surrounding whitespace", () => { + expect(normaliseBranchName(" develop ")).toBe("develop"); + }); + + test("handles null/undefined gracefully", () => { + expect(normaliseBranchName(null)).toBe(""); + expect(normaliseBranchName(undefined)).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// extractReleaseVersion +// --------------------------------------------------------------------------- + +describe("extractReleaseVersion", () => { + test("extracts version from release/vX.Y.Z", () => { + expect(extractReleaseVersion("release/v1.2.3")).toBe("1.2.3"); + expect(extractReleaseVersion("release/v0.6.0")).toBe("0.6.0"); + expect(extractReleaseVersion("release/v10.20.30")).toBe("10.20.30"); + }); + + test("extracts pre-release version", () => { + expect(extractReleaseVersion("release/v1.0.0-rc.1")).toBe("1.0.0-rc.1"); + }); + + test("returns null for non-release branches", () => { + expect(extractReleaseVersion("main")).toBeNull(); + expect(extractReleaseVersion("hotfix/some-fix")).toBeNull(); + expect(extractReleaseVersion("feat/new-thing")).toBeNull(); + }); + + test("returns null for release/ without vX.Y.Z format", () => { + expect(extractReleaseVersion("release/my-release")).toBeNull(); + expect(extractReleaseVersion("release/v1.2")).toBeNull(); + }); + + test("strips refs/heads/ prefix before matching", () => { + expect(extractReleaseVersion("refs/heads/release/v2.0.0")).toBe("2.0.0"); + }); +}); + +// --------------------------------------------------------------------------- +// isReleaseBranch +// --------------------------------------------------------------------------- + +describe("isReleaseBranch", () => { + test("accepts release/vX.Y.Z", () => { + expect(isReleaseBranch("release/v1.2.3")).toBe(true); + expect(isReleaseBranch("release/v0.6.0")).toBe(true); + }); + + test("rejects release/ without semver format", () => { + expect(isReleaseBranch("release/my-release")).toBe(false); + expect(isReleaseBranch("release/v1.2")).toBe(false); + }); + + test("rejects non-release branch names", () => { + expect(isReleaseBranch("main")).toBe(false); + expect(isReleaseBranch("develop")).toBe(false); + expect(isReleaseBranch("feat/new-feature")).toBe(false); + expect(isReleaseBranch("hotfix/urgent-fix")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isHotfixBranch +// --------------------------------------------------------------------------- + +describe("isHotfixBranch", () => { + test("accepts hotfix/", () => { + expect(isHotfixBranch("hotfix/critical-fix")).toBe(true); + expect(isHotfixBranch("hotfix/urgent-security-patch")).toBe(true); + expect(isHotfixBranch("hotfix/fix-123")).toBe(true); + }); + + test("rejects non-hotfix branches", () => { + expect(isHotfixBranch("main")).toBe(false); + expect(isHotfixBranch("develop")).toBe(false); + expect(isHotfixBranch("release/v1.2.3")).toBe(false); + expect(isHotfixBranch("fix/something")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isAllowedBranch +// --------------------------------------------------------------------------- + +describe("isAllowedBranch", () => { + test("allows release branches", () => { + expect(isAllowedBranch("release/v1.0.0")).toBe(true); + }); + + test("allows hotfix branches", () => { + expect(isAllowedBranch("hotfix/some-fix")).toBe(true); + }); + + test("blocks all other branch types", () => { + expect(isAllowedBranch("main")).toBe(false); + expect(isAllowedBranch("develop")).toBe(false); + expect(isAllowedBranch("feat/new-feature")).toBe(false); + expect(isAllowedBranch("fix/bug")).toBe(false); + expect(isAllowedBranch("chore/update-deps")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// validatePullRequestMetadata β€” release branch +// --------------------------------------------------------------------------- + +describe("validatePullRequestMetadata (release branch)", () => { + const validBody = `## Linked issues & merged PRs + +Closes #1 + +## Changelog + +See CHANGELOG.md + +### Checklist (Global DoD / PR) + +- [x] Done +`; + + test("passes for a valid release PR", () => { + const pr = { + draft: false, + title: "chore(release): v1.2.3", + body: validBody, + }; + const findings = validatePullRequestMetadata(pr, "release/v1.2.3"); + expect(findings).toHaveLength(0); + }); + + test("fails when PR title does not match expected format", () => { + const pr = { + draft: false, + title: "Release 1.2.3", + body: validBody, + }; + const findings = validatePullRequestMetadata(pr, "release/v1.2.3"); + expect(findings.some((f) => /title/i.test(f))).toBe(true); + }); + + test("fails when PR is a draft", () => { + const pr = { + draft: true, + title: "chore(release): v1.2.3", + body: validBody, + }; + const findings = validatePullRequestMetadata(pr, "release/v1.2.3"); + expect(findings.some((f) => /ready for review/i.test(f))).toBe(true); + }); + + test("fails when Linked issues section is missing", () => { + const body = `## Changelog\n\nSee CHANGELOG.md\n\n### Checklist (Global DoD / PR)\n\n- [x] Done\n`; + const pr = { draft: false, title: "chore(release): v1.2.3", body }; + const findings = validatePullRequestMetadata(pr, "release/v1.2.3"); + expect(findings.some((f) => /Linked issues/i.test(f))).toBe(true); + }); + + test("fails when Changelog section is missing", () => { + const body = `## Linked issues & merged PRs\n\nCloses #1\n\n### Checklist (Global DoD / PR)\n\n- [x] Done\n`; + const pr = { draft: false, title: "chore(release): v1.2.3", body }; + const findings = validatePullRequestMetadata(pr, "release/v1.2.3"); + expect(findings.some((f) => /Changelog/i.test(f))).toBe(true); + }); + + test("fails when Checklist section is missing", () => { + const body = `## Linked issues & merged PRs\n\nCloses #1\n\n## Changelog\n\nSee CHANGELOG.md\n`; + const pr = { draft: false, title: "chore(release): v1.2.3", body }; + const findings = validatePullRequestMetadata(pr, "release/v1.2.3"); + expect(findings.some((f) => /Checklist/i.test(f))).toBe(true); + }); + + test("fails when release branch name doesn't match vX.Y.Z format", () => { + const pr = { + draft: false, + title: "chore(release): v1.2.3", + body: validBody, + }; + const findings = validatePullRequestMetadata(pr, "release/my-release"); + expect(findings.some((f) => /release\/vX\.Y\.Z/i.test(f))).toBe(true); + }); + + test("fails when pull request payload is null", () => { + const findings = validatePullRequestMetadata(null, "release/v1.2.3"); + expect(findings.some((f) => /Missing pull request/i.test(f))).toBe(true); + }); + + test("automated release PR body shape satisfies guard", () => { + const today = new Date().toISOString().split("T")[0]; + const body = `## Linked issues & merged PRs\n\n\n\n## Changelog\n\nSee CHANGELOG.md for the [\`0.6.0\`] entry dated ${today}.\n\n### Checklist (Global DoD / PR)\n\n- [x] Release branch \`release/v0.6.0\` created from \`develop\`\n- [x] \`VERSION\` bumped to \`0.6.0\`\n`; + const pr = { draft: false, title: "chore(release): v0.6.0", body }; + const findings = validatePullRequestMetadata(pr, "release/v0.6.0"); + expect(findings).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// validatePullRequestMetadata β€” hotfix branch +// --------------------------------------------------------------------------- + +describe("validatePullRequestMetadata (hotfix branch)", () => { + const validHotfixBody = `## Linked issues + +Closes #2 + +## Incident / Root Cause + +Server was down. + +## Changelog + +Applied patch. + +### Checklist (Global DoD / PR) + +- [x] Done +`; + + test("passes for a valid hotfix PR", () => { + const pr = { + draft: false, + title: "hotfix: critical patch", + body: validHotfixBody, + }; + const findings = validatePullRequestMetadata(pr, "hotfix/critical-patch"); + expect(findings).toHaveLength(0); + }); + + test("fails when Incident / Root Cause section is missing", () => { + const body = `## Linked issues\n\nCloses #2\n\n## Changelog\n\nPatch\n\n### Checklist (Global DoD / PR)\n\n- [x] Done\n`; + const pr = { draft: false, title: "hotfix: fix", body }; + const findings = validatePullRequestMetadata(pr, "hotfix/fix"); + expect(findings.some((f) => /Incident/i.test(f))).toBe(true); + }); +}); From 30135362d52be33f6bbe7ec849cfb9029a0587a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:12:33 +0000 Subject: [PATCH 2/2] fix(release): add CHANGELOG entry for release agent hardening (#968) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01LNVr8xNwtAwfWVpaxW75Aq --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d41ad633..5682c0699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Mermaid WCAG 2.2 AA colour contrast validation** β€” Added `scripts/validation/validate-mermaid-colour-contrast.js` which checks every `style` declaration in Mermaid diagrams against a pre-verified WCAG 2.2 AA palette (minimum 4.5:1 contrast ratio). The validator supports `--changed-files` scoping for CI efficiency, flags unparseable hex values as errors, strips inline `%%` comments before parsing, and generates a dated markdown report under `.github/reports/mermaid/`. Added `npm run validate:mermaid-contrast` script. Updated `.github/workflows/validate-mermaid-pr.yml` to run all three diagram checks (syntax, accessibility, contrast) on every PR that modifies `.md`/`.mdx` files, posting a consolidated status comment. Updated `instructions/mermaid.instructions.md` with the approved seven-role WCAG AA palette and required structure. Fixed all existing diagram `style` declarations across `README.md`, `docs/AGENT_CREATION.md`, `profile/README.md`, `scripts/README.md`, `tests/README.md`, and `.github/ISSUE_TEMPLATE/README.md` to use the approved palette triples (`fill`, `color`, `stroke`). ([#977](https://github.com/lightspeedwp/.github/pull/977), [#976](https://github.com/lightspeedwp/.github/issues/976)) +### Fixed + +- **Release agent hardening** β€” Fixed four bugs in `scripts/agents/release.agent.js`: (1) regex escape `\\d+` β†’ `\d+` in `getMergedPRs` so PR numbers are correctly extracted from `git log`; (2) automated release PR body now includes all three sections (`## Linked issues & merged PRs`, `## Changelog`, `### Checklist (Global DoD / PR)`) required by the main-branch-guard; (3) `createReleasePR` (shell provider) now writes the body to a temp file and uses `--body-file` to avoid shell injection from backtick-containing markdown; (4) corrected Husky v9 command from `npx husky run pre-commit` to `npx lint-staged`. Added full test suites for `changelogUtils.cjs`, `validate-main-branch-pr.cjs`, and `release.agent.js` (ESM subprocess pattern); rewrote the stub in `validate-changelog.test.js` with real CLI and integration tests. Clarified the `develop β†’ release/vX.Y.Z β†’ main` flow in the release issue template. ([#1018](https://github.com/lightspeedwp/.github/pull/1018), [#968](https://github.com/lightspeedwp/.github/issues/968)) + ### Changed - **Frontmatter standardisation across issue templates, docs, and validation** β€” Normalised markdown issue templates to use `name` + `about`, aligned the frontmatter schema and validation scripts with the GitHub-supported template contract, and updated the issue-creation workflow plus related docs, instructions, and prompts to match the canonical template layout. ([#1016](https://github.com/lightspeedwp/.github/pull/1016), [#1012](https://github.com/lightspeedwp/.github/issues/1012), [#1015](https://github.com/lightspeedwp/.github/issues/1015))