From 60ac8c3946f7695550a75b4a20b4f3f8e3891532 Mon Sep 17 00:00:00 2001 From: Nate Corcoran Date: Thu, 21 May 2026 13:40:21 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(actions):=20add=20compute-highest-comm?= =?UTF-8?q?it-type=20foundation=20action=20(v4=20=C2=A711=20PR=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-logic action: parse a JSON array of conventional commits and emit the highest-priority type plus implied semver bump. Composite action that runs a single Node step against action.mjs. Action.mjs exports parseCommit/computeHighest/bumpFor/TYPE_PRIORITY so the sibling test.mjs can exercise the pure logic without spinning up an Actions runner. 30 test assertions cover: - subject parsing (plain / scoped / breaking-mark / breaking-footer) - bump derivation per type - priority ranking (feat > fix > perf > ... > chore > unknown) - edge cases (empty list, all non-conventional, unknown types) Run: `cd .github/actions/git/steps/compute-highest-commit-type && node test.mjs` Not wired into any workflow yet; consumed in subsequent PRs by normalize-pr-title (Β§6.4) and check-release-commit refactor. --- .../compute-highest-commit-type/action.mjs | 141 ++++++++++++++++++ .../compute-highest-commit-type/action.yml | 33 ++++ .../compute-highest-commit-type/test.mjs | 124 +++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 .github/actions/git/steps/compute-highest-commit-type/action.mjs create mode 100644 .github/actions/git/steps/compute-highest-commit-type/action.yml create mode 100644 .github/actions/git/steps/compute-highest-commit-type/test.mjs diff --git a/.github/actions/git/steps/compute-highest-commit-type/action.mjs b/.github/actions/git/steps/compute-highest-commit-type/action.mjs new file mode 100644 index 00000000..a8e80dff --- /dev/null +++ b/.github/actions/git/steps/compute-highest-commit-type/action.mjs @@ -0,0 +1,141 @@ +/** + * @fileoverview Compute the highest-priority Conventional-Commit type across + * a set of commits, and the implied semver bump. Pure logic β€” exports the + * core functions for tests; the IIFE at the bottom drives the action. + * + * @module @cldmv/.github.git.steps.compute-highest-commit-type + */ + +import { readFileSync } from "node:fs"; +import { getInput, setOutputs } from "../../../common/common/core.mjs"; + +/** + * Conventional types in priority order (highest = most important). Used to + * pick the dominant type when a range contains multiple. Breaking commits + * outrank all of these regardless of their own type. + * + * @public + */ +export const TYPE_PRIORITY = [ + "feat", + "fix", + "perf", + "revert", + "refactor", + "style", + "docs", + "test", + "build", + "ci", + "chore" +]; + +/** + * Map a (type, isBreaking) pair to an implied semver bump. + * + * @public + * @param {string} type - Conventional type (e.g. "feat", "fix"). Empty string + * when nothing parsed. + * @param {boolean} isBreaking - Whether any commit declared a breaking change. + * @returns {"major"|"minor"|"patch"|"none"} + */ +export function bumpFor(type, isBreaking) { + if (isBreaking) return "major"; + if (type === "feat") return "minor"; + if (type === "fix" || type === "perf" || type === "revert") return "patch"; + return "none"; +} + +const CONVENTIONAL_SUBJECT_RE = /^([a-z]+)(?:\([^)]*\))?(!)?:\s*.+$/; +const BREAKING_FOOTER_RE = /^BREAKING[\s-]CHANGE:/m; + +/** + * Parse a single commit subject + optional body into { type, isBreaking }. + * Returns null when the subject doesn't match Conventional Commits format. + * + * @public + * @param {string} subject - First line of the commit message. + * @param {string} [body=""] - Remainder of the commit message (may be empty). + * @returns {{ type: string, isBreaking: boolean } | null} + */ +export function parseCommit(subject, body = "") { + if (typeof subject !== "string") return null; + const m = subject.match(CONVENTIONAL_SUBJECT_RE); + if (!m) return null; + const type = m[1].toLowerCase(); + const breakingMark = !!m[2]; + const breakingFooter = typeof body === "string" && BREAKING_FOOTER_RE.test(body); + return { type, isBreaking: breakingMark || breakingFooter }; +} + +/** + * Reduce a list of parsed commits to the highest-priority result. + * + * @public + * @param {Array<{ subject: string, body?: string }>} commits + * @returns {{ highestType: string, isBreaking: boolean, bump: "major"|"minor"|"patch"|"none" }} + */ +export function computeHighest(commits) { + let highestType = ""; + let highestRank = Infinity; + let isBreaking = false; + + for (const c of commits || []) { + const parsed = parseCommit(c?.subject ?? "", c?.body ?? ""); + if (!parsed) continue; + if (parsed.isBreaking) isBreaking = true; + const rank = TYPE_PRIORITY.indexOf(parsed.type); + // Unknown types rank after the known list but still get considered. + const effectiveRank = rank === -1 ? TYPE_PRIORITY.length : rank; + if (effectiveRank < highestRank) { + highestRank = effectiveRank; + highestType = parsed.type; + } + } + + return { + highestType, + isBreaking, + bump: bumpFor(highestType, isBreaking) + }; +} + +function loadCommits() { + const file = getInput("commits-file"); + const inline = getInput("commits-json"); + if (file && inline) { + throw new Error("commits-file and commits-json are mutually exclusive β€” pass one, not both"); + } + if (!file && !inline) { + throw new Error("either commits-file or commits-json must be provided"); + } + const raw = file ? readFileSync(file, "utf8") : inline; + let parsed; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error(`Failed to parse commits JSON: ${e.message}`); + } + if (!Array.isArray(parsed)) { + throw new Error("commits JSON must be an array"); + } + return parsed; +} + +// Action entry β€” only runs when this module is the program entry, not when +// imported by test.mjs. +if (import.meta.url === `file://${process.argv[1]}`) { + try { + const commits = loadCommits(); + const { highestType, isBreaking, bump } = computeHighest(commits); + setOutputs({ + "highest-type": highestType, + "is-breaking": String(isBreaking), + bump + }); + console.log(`πŸ“Š Highest type: ${highestType || "(none)"} | breaking: ${isBreaking} | bump: ${bump}`); + } catch (error) { + console.error(`::error::${error.message}`); + process.exit(1); + } +} diff --git a/.github/actions/git/steps/compute-highest-commit-type/action.yml b/.github/actions/git/steps/compute-highest-commit-type/action.yml new file mode 100644 index 00000000..38b52a8a --- /dev/null +++ b/.github/actions/git/steps/compute-highest-commit-type/action.yml @@ -0,0 +1,33 @@ +name: "Compute Highest Commit Type" +description: "Parse a JSON array of commits in Conventional Commits format and return the highest-priority type plus the implied semver bump. Used by normalize-pr-title and check-release-commit to pick the dominant type across a range." + +inputs: + commits-file: + description: "Path to a JSON file containing an array of commit objects with at least { subject, body } fields. Mutually exclusive with commits-json." + required: false + commits-json: + description: "Inline JSON array of commit objects with at least { subject, body } fields. Mutually exclusive with commits-file." + required: false + +outputs: + highest-type: + description: "Highest-priority conventional type found (e.g. 'feat', 'fix', 'chore'). Empty string when no commit parses as conventional." + value: ${{ steps.compute.outputs.highest-type }} + is-breaking: + description: "'true' when any commit declares a breaking change (via '!' marker or 'BREAKING CHANGE:' footer)." + value: ${{ steps.compute.outputs.is-breaking }} + bump: + description: "Implied semver bump: 'major' / 'minor' / 'patch' / 'none'." + value: ${{ steps.compute.outputs.bump }} + +runs: + using: "composite" + steps: + - name: Compute highest commit type + id: compute + shell: bash + env: + INPUT_COMMITS_FILE: ${{ inputs.commits-file }} + INPUT_COMMITS_JSON: ${{ inputs.commits-json }} + run: | + node "${{ github.action_path }}/action.mjs" diff --git a/.github/actions/git/steps/compute-highest-commit-type/test.mjs b/.github/actions/git/steps/compute-highest-commit-type/test.mjs new file mode 100644 index 00000000..72bb4170 --- /dev/null +++ b/.github/actions/git/steps/compute-highest-commit-type/test.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * @fileoverview Unit tests for compute-highest-commit-type pure logic. + * Run directly: `node test.mjs` in this directory. Exits non-zero on failure. + */ + +import { parseCommit, computeHighest, bumpFor, TYPE_PRIORITY } from "./action.mjs"; + +let failures = 0; + +function eq(actual, expected, label) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { + console.log(` βœ… ${label}`); + } else { + console.error(` ❌ ${label}`); + console.error(` expected: ${JSON.stringify(expected)}`); + console.error(` actual: ${JSON.stringify(actual)}`); + failures++; + } +} + +console.log("parseCommit:"); +eq(parseCommit("feat: add foo"), { type: "feat", isBreaking: false }, "plain feat"); +eq(parseCommit("fix(parser): handle empty input"), { type: "fix", isBreaking: false }, "fix with scope"); +eq(parseCommit("feat!: drop legacy API"), { type: "feat", isBreaking: true }, "feat with breaking mark"); +eq(parseCommit("chore(deps)!: bump major dep"), { type: "chore", isBreaking: true }, "scoped + breaking mark"); +eq( + parseCommit("feat: add foo", "BREAKING CHANGE: removes /v1"), + { type: "feat", isBreaking: true }, + "breaking via body footer" +); +eq( + parseCommit("feat: add foo", "BREAKING-CHANGE: removes /v1"), + { type: "feat", isBreaking: true }, + "breaking via hyphenated body footer" +); +eq(parseCommit("Merge pull request #10 from foo"), null, "non-conventional β†’ null"); +eq(parseCommit("FEAT: caps"), null, "uppercase type rejected"); +eq(parseCommit(""), null, "empty subject"); +eq(parseCommit(null), null, "null subject"); + +console.log("\nbumpFor:"); +eq(bumpFor("feat", false), "minor", "feat β†’ minor"); +eq(bumpFor("fix", false), "patch", "fix β†’ patch"); +eq(bumpFor("perf", false), "patch", "perf β†’ patch"); +eq(bumpFor("revert", false), "patch", "revert β†’ patch"); +eq(bumpFor("chore", false), "none", "chore β†’ none"); +eq(bumpFor("docs", false), "none", "docs β†’ none"); +eq(bumpFor("feat", true), "major", "feat + breaking β†’ major"); +eq(bumpFor("chore", true), "major", "chore + breaking β†’ major"); +eq(bumpFor("", false), "none", "empty type β†’ none"); + +console.log("\ncomputeHighest:"); +eq( + computeHighest([ + { subject: "chore: cleanup", body: "" }, + { subject: "feat: add foo", body: "" }, + { subject: "fix: bar", body: "" } + ]), + { highestType: "feat", isBreaking: false, bump: "minor" }, + "feat wins over fix + chore" +); +eq( + computeHighest([ + { subject: "fix: bar", body: "" }, + { subject: "chore: cleanup", body: "" } + ]), + { highestType: "fix", isBreaking: false, bump: "patch" }, + "fix wins over chore" +); +eq( + computeHighest([ + { subject: "chore!: rip out v1", body: "" } + ]), + { highestType: "chore", isBreaking: true, bump: "major" }, + "breaking outranks bump category β€” chore! β†’ major" +); +eq( + computeHighest([ + { subject: "feat: add foo", body: "BREAKING CHANGE: removes /v1" } + ]), + { highestType: "feat", isBreaking: true, bump: "major" }, + "footer-declared breaking β†’ major" +); +eq( + computeHighest([]), + { highestType: "", isBreaking: false, bump: "none" }, + "empty list β†’ all defaults" +); +eq( + computeHighest([ + { subject: "Merge pull request #10 from foo" }, + { subject: "non-conventional message" } + ]), + { highestType: "", isBreaking: false, bump: "none" }, + "all non-conventional β†’ all defaults" +); +eq( + computeHighest([ + { subject: "weirdtype: a new category" }, + { subject: "fix: bar" } + ]), + { highestType: "fix", isBreaking: false, bump: "patch" }, + "unknown type ranks below known types" +); +eq( + computeHighest([ + { subject: "weirdtype: foo" } + ]), + { highestType: "weirdtype", isBreaking: false, bump: "none" }, + "unknown type still surfaces when only choice (bump=none)" +); + +console.log("\nTYPE_PRIORITY ordering:"); +eq(TYPE_PRIORITY[0], "feat", "feat is first"); +eq(TYPE_PRIORITY[1], "fix", "fix is second"); +eq(TYPE_PRIORITY.indexOf("perf") < TYPE_PRIORITY.indexOf("refactor"), true, "perf ranks above refactor"); + +if (failures > 0) { + console.error(`\n❌ ${failures} test(s) failed`); + process.exit(1); +} +console.log("\nβœ… all tests passed"); From d33142cc5d033b61042b2551b3eaf7455d507ade Mon Sep 17 00:00:00 2001 From: Nate Corcoran Date: Thu, 21 May 2026 13:42:57 -0700 Subject: [PATCH 2/7] =?UTF-8?q?feat(actions):=20add=20normalize-pr-title?= =?UTF-8?q?=20foundation=20action=20(v4=20=C2=A711=20PR=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composite action implementing Β§6.4 of the v4 design. Skips bot-authored PRs, the long-running release PRs (next/hotfix β†’ master), and titles starting with 'release:' (the v3 escape-hatch carries forward). action.mjs imports compute-highest-commit-type's pure logic (parseCommit / TYPE_PRIORITY / computeHighest) so it doesn't re-implement the parser. Exports the pure logic for test.mjs: - extractTitleParts: regex parse of a current title - shouldSkip: pre-API skip decision - titleConforms: 'title is acceptable given required type+breaking' - buildNewTitle: type/scope/breaking/summary β†’ new title - summaryFromSubject: strip conventional prefix to get bare summary - findRepresentativeCommit: pick chronologically-oldest matching commit using findLast (matches the v3.2.x title-suffix logic) Side-effecting main() does: fetch PR + commits β†’ compute highest β†’ gate via shouldSkip/titleConforms β†’ PATCH title β†’ sentinel-deduped comment via COMMENT_SENTINEL. Not wired into any workflow yet; consumed by local-pr-title-normalizer.yml in a later PR. --- .../steps/normalize-pr-title/action.mjs | 252 ++++++++++++++++++ .../steps/normalize-pr-title/action.yml | 54 ++++ .../github/steps/normalize-pr-title/test.mjs | 132 +++++++++ 3 files changed, 438 insertions(+) create mode 100644 .github/actions/github/steps/normalize-pr-title/action.mjs create mode 100644 .github/actions/github/steps/normalize-pr-title/action.yml create mode 100644 .github/actions/github/steps/normalize-pr-title/test.mjs diff --git a/.github/actions/github/steps/normalize-pr-title/action.mjs b/.github/actions/github/steps/normalize-pr-title/action.mjs new file mode 100644 index 00000000..4f667428 --- /dev/null +++ b/.github/actions/github/steps/normalize-pr-title/action.mjs @@ -0,0 +1,252 @@ +/** + * @fileoverview Normalize a contributor PR's title to Conventional Commits + * format, derived from the highest-priority commit in the PR. Implements + * Β§6.4 of the v4 design (docs/conventions/release-flow-v4.md). + * + * Pure logic functions are exported for test.mjs. The side-effecting main + * block (gated on import.meta.url === argv[1]) does the API calls. + * + * @module @cldmv/.github.github.steps.normalize-pr-title + */ + +import { api, parseRepo } from "../../api/_api/core.mjs"; +import { getInput, setOutputs, getBooleanInput } from "../../../common/common/core.mjs"; +import { + TYPE_PRIORITY, + parseCommit, + computeHighest +} from "../../../git/steps/compute-highest-commit-type/action.mjs"; + +/** + * Sentinel phrase used to dedup the "title was normalized" comment. Must be + * stable across runs because we scan past comments for it; changing the + * phrase resets the dedup window. + * @public + */ +export const COMMENT_SENTINEL = "_Auto-normalized PR title:_"; + +const CONVENTIONAL_TITLE_RE = /^([a-z]+)(?:\(([^)]*)\))?(!)?:\s+(.+)$/; + +/** + * Parse a PR title and extract its conventional parts. Returns null when the + * title doesn't conform. + * + * @public + * @param {string} title + * @returns {{ type: string, scope: string, breakingMark: boolean, summary: string } | null} + */ +export function extractTitleParts(title) { + if (typeof title !== "string") return null; + const m = title.match(CONVENTIONAL_TITLE_RE); + if (!m) return null; + return { + type: m[1].toLowerCase(), + scope: m[2] || "", + breakingMark: !!m[3], + summary: m[4] + }; +} + +/** + * Decide whether to skip normalization based on PR context, before any API + * traffic. The four skip conditions from Β§6.4 of the design doc are: + * 1. PR author is a Bot (cldmv-bot, github-actions, dependabot, etc.) + * 2. Release PR β€” base = master AND head ∈ { next, hotfix } + * 3. Title starts with "release:" β€” escape-hatch override + * 4. (Not handled here; "title already conforms" is checked later) + * + * @public + * @param {object} ctx + * @param {string} [ctx.userType] - PR author `user.type` ("User"|"Bot") + * @param {string} [ctx.baseRef] - PR base branch + * @param {string} [ctx.headRef] - PR head branch + * @param {string} [ctx.title] - Current PR title + * @returns {{ skip: boolean, reason: string }} + */ +export function shouldSkip({ userType, baseRef, headRef, title }) { + if (userType === "Bot") return { skip: true, reason: "PR author is a Bot" }; + if (baseRef === "master" && (headRef === "next" || headRef === "hotfix")) { + return { skip: true, reason: "Release PR (next/hotfix β†’ master) β€” owned by the release flow" }; + } + if (typeof title === "string" && /^release:\s/i.test(title)) { + return { skip: true, reason: "Title starts with 'release:' β€” escape-hatch override" }; + } + return { skip: false, reason: "" }; +} + +/** + * Check whether a current title is already acceptable given the required + * type/breaking from the PR's commits. The title is acceptable when: + * - It parses as a conventional title, AND + * - Its type's priority is at least as high as the required type + * (lower index in TYPE_PRIORITY = higher priority), AND + * - If breaking is required, the title carries the `!` mark. + * + * Returns true even when the title's type is *more* important than required + * (e.g., contributor wrote `feat:` but commits only had `fix:` β€” we respect + * the contributor). + * + * @public + * @param {string} title + * @param {string} requiredType + * @param {boolean} requiredBreaking + * @returns {boolean} + */ +export function titleConforms(title, requiredType, requiredBreaking) { + const parts = extractTitleParts(title); + if (!parts) return false; + if (requiredBreaking && !parts.breakingMark) return false; + if (!requiredType) return true; // No required type means anything conventional passes. + const currentIdx = TYPE_PRIORITY.indexOf(parts.type); + const requiredIdx = TYPE_PRIORITY.indexOf(requiredType); + // Unknown types: only accept exact-type match. + if (currentIdx === -1 || requiredIdx === -1) { + return parts.type === requiredType; + } + return currentIdx <= requiredIdx; +} + +/** + * Build a new conventional title from a chosen type / breaking / summary / + * optional scope. + * + * @public + */ +export function buildNewTitle({ type, isBreaking, summary, scope }) { + const scopeStr = scope ? `(${scope})` : ""; + const breakingStr = isBreaking ? "!" : ""; + return `${type}${scopeStr}${breakingStr}: ${summary}`; +} + +/** + * Strip the "()?(!)?: " prefix from a conventional subject, + * leaving just the summary text. Returns the input verbatim when it doesn't + * look conventional. + * + * @public + */ +export function summaryFromSubject(subject) { + if (typeof subject !== "string") return ""; + const m = subject.match(/^[a-z]+(?:\([^)]*\))?(?:!)?:\s+(.+)$/); + return m ? m[1] : subject; +} + +/** + * Find the FIRST commit (chronologically β€” assumes input is reverse-chrono + * so we use findLast) whose parsed type matches `targetType`. Used to pick a + * representative summary for the new title. + * + * @public + */ +export function findRepresentativeCommit(commits, targetType) { + if (!Array.isArray(commits) || !targetType) return null; + // `commits` is typically returned newest-first by the GitHub API; the + // "first" commit in the PR (the one the contributor wrote first) is at + // the end. findLast picks the chronologically-oldest matching one. + return commits.findLast((c) => { + const p = parseCommit(c?.subject ?? c?.commit?.message ?? "", c?.body ?? ""); + return p && p.type === targetType; + }) || null; +} + +// ---- side-effecting main flow (gated to script entry only) ---------------- + +async function fetchPR(owner, repo, prNumber, token) { + return api("GET", `/pulls/${prNumber}`, null, { token, owner, repo }); +} + +async function fetchPRCommits(owner, repo, prNumber, token) { + const raw = await api("GET", `/pulls/${prNumber}/commits?per_page=100`, null, { token, owner, repo }); + // Normalize to the shape compute-highest-commit-type expects: { subject, body }. + return (raw || []).map((c) => { + const msg = c?.commit?.message || ""; + const [subject, ...rest] = msg.split("\n"); + const body = rest.join("\n").trim(); + return { subject, body, sha: c?.sha }; + }); +} + +async function patchTitle(owner, repo, prNumber, newTitle, token) { + return api("PATCH", `/pulls/${prNumber}`, { title: newTitle }, { token, owner, repo }); +} + +async function hasSentinelComment(owner, repo, prNumber, token) { + const comments = await api("GET", `/issues/${prNumber}/comments?per_page=100`, null, { token, owner, repo }); + return (comments || []).some((c) => typeof c?.body === "string" && c.body.includes(COMMENT_SENTINEL)); +} + +async function postComment(owner, repo, prNumber, oldTitle, newTitle, highestType, token) { + const body = `${COMMENT_SENTINEL} rewrote PR title to match the highest-priority commit type (\`${highestType}\`).\n\n- **Before:** \`${oldTitle}\`\n- **After:** \`${newTitle}\`\n\nIf this isn't what you want, edit the title β€” the normalizer won't re-fire as long as the title stays conventional.`; + return api("POST", `/issues/${prNumber}/comments`, { body }, { token, owner, repo }); +} + +async function main() { + const token = process.env.GITHUB_TOKEN || getInput("github-token", { required: true }); + const prNumber = getInput("pr-number", { required: true }); + const dryRun = getBooleanInput("dry-run", false); + let baseRef = getInput("base-ref"); + let headRef = getInput("head-ref"); + let userType = getInput("user-type"); + const { owner, repo } = parseRepo(process.env.GITHUB_REPOSITORY); + + const pr = await fetchPR(owner, repo, prNumber, token); + const currentTitle = pr?.title || ""; + if (!baseRef) baseRef = pr?.base?.ref || ""; + if (!headRef) headRef = pr?.head?.ref || ""; + if (!userType) userType = pr?.user?.type || ""; + + const skip = shouldSkip({ userType, baseRef, headRef, title: currentTitle }); + if (skip.skip) { + console.log(`⏭️ Skipped: ${skip.reason}`); + setOutputs({ rewritten: "false", "new-title": currentTitle, skipped: "true", "skip-reason": skip.reason }); + return; + } + + const commits = await fetchPRCommits(owner, repo, prNumber, token); + const { highestType, isBreaking } = computeHighest(commits); + + if (!highestType) { + const reason = "No commits in PR parse as Conventional Commits"; + console.log(`⏭️ Skipped: ${reason}`); + setOutputs({ rewritten: "false", "new-title": currentTitle, skipped: "true", "skip-reason": reason }); + return; + } + + if (titleConforms(currentTitle, highestType, isBreaking)) { + console.log(`βœ… Title already conforms (current type ranks β‰₯ required type \`${highestType}\`).`); + setOutputs({ rewritten: "false", "new-title": currentTitle, skipped: "false", "skip-reason": "" }); + return; + } + + const representative = findRepresentativeCommit(commits, highestType); + const summary = summaryFromSubject(representative?.subject ?? currentTitle); + const newTitle = buildNewTitle({ type: highestType, isBreaking, summary, scope: "" }); + + console.log(`✏️ Rewriting PR title:`); + console.log(` before: ${currentTitle}`); + console.log(` after: ${newTitle}`); + + if (dryRun) { + console.log("ℹ️ dry-run=true β€” skipping PATCH + comment."); + setOutputs({ rewritten: "false", "new-title": newTitle, skipped: "false", "skip-reason": "dry-run" }); + return; + } + + await patchTitle(owner, repo, prNumber, newTitle, token); + + if (!(await hasSentinelComment(owner, repo, prNumber, token))) { + await postComment(owner, repo, prNumber, currentTitle, newTitle, highestType, token); + console.log("πŸ’¬ Posted explanatory comment (first time)."); + } else { + console.log("πŸ’¬ Sentinel comment already present β€” skipping comment post."); + } + + setOutputs({ rewritten: "true", "new-title": newTitle, skipped: "false", "skip-reason": "" }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exit(1); + }); +} diff --git a/.github/actions/github/steps/normalize-pr-title/action.yml b/.github/actions/github/steps/normalize-pr-title/action.yml new file mode 100644 index 00000000..5ab5902c --- /dev/null +++ b/.github/actions/github/steps/normalize-pr-title/action.yml @@ -0,0 +1,54 @@ +name: "Normalize PR Title" +description: "Rewrite a contributor PR's title to conform to Conventional Commits format, derived from the highest-priority commit in the PR. Skips bot-authored PRs, the long-running release PRs (next/hotfix β†’ master), and titles starting with 'release:'." + +inputs: + pr-number: + description: "PR number to inspect / rewrite" + required: true + github-token: + description: "GitHub token with PR write access (App installation token preferred)" + required: false + default: ${{ github.token }} + base-ref: + description: "PR base branch (e.g., 'next', 'master'). When empty, fetched from API." + required: false + head-ref: + description: "PR head branch. When empty, fetched from API." + required: false + user-type: + description: "PR author's `user.type` from the event payload ('User' or 'Bot'). When empty, fetched from API." + required: false + dry-run: + description: "When 'true', compute and log the would-be title but do not PATCH the PR or post any comment." + required: false + default: "false" + +outputs: + rewritten: + description: "'true' when the PR title was actually rewritten" + value: ${{ steps.normalize.outputs.rewritten }} + new-title: + description: "The title after normalization (or the unchanged current title)" + value: ${{ steps.normalize.outputs.new-title }} + skipped: + description: "'true' when the action exited early due to a skip condition" + value: ${{ steps.normalize.outputs.skipped }} + skip-reason: + description: "Human-readable reason when skipped is 'true'" + value: ${{ steps.normalize.outputs.skip-reason }} + +runs: + using: "composite" + steps: + - name: Normalize PR title + id: normalize + shell: bash + env: + INPUT_PR_NUMBER: ${{ inputs.pr-number }} + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_BASE_REF: ${{ inputs.base-ref }} + INPUT_HEAD_REF: ${{ inputs.head-ref }} + INPUT_USER_TYPE: ${{ inputs.user-type }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + run: | + node "${{ github.action_path }}/action.mjs" diff --git a/.github/actions/github/steps/normalize-pr-title/test.mjs b/.github/actions/github/steps/normalize-pr-title/test.mjs new file mode 100644 index 00000000..abb19845 --- /dev/null +++ b/.github/actions/github/steps/normalize-pr-title/test.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * @fileoverview Unit tests for normalize-pr-title pure logic. + * Run: `node test.mjs`. No network β€” only exercises exported functions. + */ + +import { + extractTitleParts, + shouldSkip, + titleConforms, + buildNewTitle, + summaryFromSubject, + findRepresentativeCommit, + COMMENT_SENTINEL +} from "./action.mjs"; + +let failures = 0; + +function eq(actual, expected, label) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { + console.log(` βœ… ${label}`); + } else { + console.error(` ❌ ${label}`); + console.error(` expected: ${JSON.stringify(expected)}`); + console.error(` actual: ${JSON.stringify(actual)}`); + failures++; + } +} + +console.log("extractTitleParts:"); +eq( + extractTitleParts("feat: add foo"), + { type: "feat", scope: "", breakingMark: false, summary: "add foo" }, + "plain feat title" +); +eq( + extractTitleParts("fix(parser): handle empty input"), + { type: "fix", scope: "parser", breakingMark: false, summary: "handle empty input" }, + "fix with scope" +); +eq( + extractTitleParts("chore(deps)!: bump major dep"), + { type: "chore", scope: "deps", breakingMark: true, summary: "bump major dep" }, + "scoped breaking" +); +eq(extractTitleParts("Some random title"), null, "non-conventional β†’ null"); +eq(extractTitleParts(""), null, "empty title β†’ null"); + +console.log("\nshouldSkip:"); +eq( + shouldSkip({ userType: "Bot", baseRef: "next", headRef: "feat/x", title: "feat: x" }), + { skip: true, reason: "PR author is a Bot" }, + "bot user β†’ skip" +); +eq( + shouldSkip({ userType: "User", baseRef: "master", headRef: "next", title: "release: v3.3.0" }), + { skip: true, reason: "Release PR (next/hotfix β†’ master) β€” owned by the release flow" }, + "next β†’ master β†’ skip" +); +eq( + shouldSkip({ userType: "User", baseRef: "master", headRef: "hotfix", title: "release: v3.2.5" }), + { skip: true, reason: "Release PR (next/hotfix β†’ master) β€” owned by the release flow" }, + "hotfix β†’ master β†’ skip" +); +eq( + shouldSkip({ userType: "User", baseRef: "next", headRef: "feat/x", title: "release: emergency override" }), + { skip: true, reason: "Title starts with 'release:' β€” escape-hatch override" }, + "release: prefix β†’ skip" +); +eq( + shouldSkip({ userType: "User", baseRef: "next", headRef: "feat/x", title: "feat: add x" }), + { skip: false, reason: "" }, + "normal contributor PR β†’ no skip" +); +eq( + shouldSkip({ userType: "User", baseRef: "master", headRef: "feat/x", title: "feat: x" }), + { skip: false, reason: "" }, + "feature PR to master (rare) is not skipped β€” only release-PRs from next/hotfix are" +); + +console.log("\ntitleConforms:"); +eq(titleConforms("feat: x", "feat", false), true, "exact match"); +eq(titleConforms("feat: x", "fix", false), true, "title type ranks higher (feat > fix)"); +eq(titleConforms("chore: x", "feat", false), false, "title type ranks lower (chore < feat)"); +eq(titleConforms("feat!: x", "feat", true), true, "breaking required + breaking marked"); +eq(titleConforms("feat: x", "feat", true), false, "breaking required but not marked"); +eq(titleConforms("Some title", "feat", false), false, "non-conventional title"); +eq(titleConforms("feat: x", "", false), true, "no required type β†’ conventional passes"); + +console.log("\nbuildNewTitle:"); +eq(buildNewTitle({ type: "feat", isBreaking: false, summary: "add foo" }), "feat: add foo", "plain feat"); +eq(buildNewTitle({ type: "feat", isBreaking: true, summary: "rip out v1" }), "feat!: rip out v1", "breaking feat"); +eq( + buildNewTitle({ type: "fix", isBreaking: false, summary: "bar", scope: "parser" }), + "fix(parser): bar", + "with scope" +); + +console.log("\nsummaryFromSubject:"); +eq(summaryFromSubject("feat: add foo"), "add foo", "plain feat strips prefix"); +eq(summaryFromSubject("fix(scope)!: bar"), "bar", "scoped+breaking strips prefix"); +eq(summaryFromSubject("just a regular subject"), "just a regular subject", "non-conventional passes through"); + +console.log("\nfindRepresentativeCommit (uses findLast β†’ chrono-oldest match):"); +const fixturesReverseChrono = [ + { subject: "chore: cleanup", body: "" }, // newest + { subject: "feat: add bar", body: "" }, // newer of two feats + { subject: "fix: a small fix", body: "" }, + { subject: "feat: add foo", body: "" } // oldest feat β€” should be picked +]; +eq( + findRepresentativeCommit(fixturesReverseChrono, "feat")?.subject, + "feat: add foo", + "oldest matching feat picked" +); +eq( + findRepresentativeCommit(fixturesReverseChrono, "docs"), + null, + "no docs commit β†’ null" +); +eq(findRepresentativeCommit([], "feat"), null, "empty input β†’ null"); +eq(findRepresentativeCommit(null, "feat"), null, "null input β†’ null"); + +console.log("\nCOMMENT_SENTINEL:"); +eq(typeof COMMENT_SENTINEL === "string" && COMMENT_SENTINEL.length > 0, true, "sentinel is a non-empty string"); + +if (failures > 0) { + console.error(`\n❌ ${failures} test(s) failed`); + process.exit(1); +} +console.log("\nβœ… all tests passed"); From 35d23d0e3c2da039f7b4c383a0573ef8286233f1 Mon Sep 17 00:00:00 2001 From: Nate Corcoran Date: Thu, 21 May 2026 13:44:16 -0700 Subject: [PATCH 3/7] =?UTF-8?q?feat(actions):=20add=20redirect-hotfix-pr?= =?UTF-8?q?=20foundation=20action=20(v4=20=C2=A711=20PR=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Β§6.5 of the v4 design. When a contributor opens a PR with a head branch matching ^(hotfix|security)/, retarget its base from the default ('next' under v4) to 'hotfix'. Idempotent β€” skips bot-created PRs, non-matching head refs, and PRs already on the target base. action.mjs exports the pure logic for test.mjs: - compilePattern: regex source β†’ RegExp (with default fallback) - shouldSkip: gating decision combining user-type, head-match, base-already-target - buildCommentBody: explanatory comment template using the sentinel Sentinel COMMENT_SENTINEL = '_Auto-redirected PR base:_' (different from normalize-pr-title's sentinel so the two actions can both post to the same PR without interfering). Side-effecting main(): fetch PR (fill missing refs/user-type) β†’ gate β†’ PATCH base β†’ sentinel-deduped comment. Not wired into any workflow yet. --- .../steps/redirect-hotfix-pr/action.mjs | 124 ++++++++++++++++++ .../steps/redirect-hotfix-pr/action.yml | 64 +++++++++ .../github/steps/redirect-hotfix-pr/test.mjs | 81 ++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 .github/actions/github/steps/redirect-hotfix-pr/action.mjs create mode 100644 .github/actions/github/steps/redirect-hotfix-pr/action.yml create mode 100644 .github/actions/github/steps/redirect-hotfix-pr/test.mjs diff --git a/.github/actions/github/steps/redirect-hotfix-pr/action.mjs b/.github/actions/github/steps/redirect-hotfix-pr/action.mjs new file mode 100644 index 00000000..509968c3 --- /dev/null +++ b/.github/actions/github/steps/redirect-hotfix-pr/action.mjs @@ -0,0 +1,124 @@ +/** + * @fileoverview Redirect a PR's base branch to the hotfix lane when the head + * branch matches the hotfix/security pattern. Implements Β§6.5 of the v4 + * design (docs/conventions/release-flow-v4.md). + * + * Pure logic functions are exported for test.mjs. Side-effecting main is + * gated to script-entry only. + * + * @module @cldmv/.github.github.steps.redirect-hotfix-pr + */ + +import { api, parseRepo } from "../../api/_api/core.mjs"; +import { getInput, setOutputs, getBooleanInput } from "../../../common/common/core.mjs"; + +export const COMMENT_SENTINEL = "_Auto-redirected PR base:_"; + +/** + * Compile the head-branch pattern. Accepts a regex source string; returns + * a RegExp anchored at the start of the ref. + * + * @public + */ +export function compilePattern(source) { + const src = (source || "").trim() || "^(hotfix|security)/"; + return new RegExp(src); +} + +/** + * Decide whether to skip redirection. Skip when: + * 1. Author is a Bot. + * 2. Head ref doesn't match the hotfix-branch pattern. + * 3. Base ref already equals the target. + * + * @public + */ +export function shouldSkip({ userType, headRef, baseRef, targetBase, headPattern }) { + if (userType === "Bot") return { skip: true, reason: "PR author is a Bot" }; + if (!headRef || !headPattern.test(headRef)) { + return { skip: true, reason: `Head '${headRef}' does not match hotfix pattern ${headPattern}` }; + } + if (baseRef === targetBase) { + return { skip: true, reason: `PR already targets '${targetBase}'` }; + } + return { skip: false, reason: "" }; +} + +/** + * Build the explanatory comment body. Uses the COMMENT_SENTINEL prefix so + * future runs can detect a prior post. + * + * @public + */ +export function buildCommentBody(oldBase, newBase) { + return `${COMMENT_SENTINEL} retargeted this PR from \`${oldBase}\` to \`${newBase}\` because the head branch looks like a hotfix.\n\nIf this was not what you wanted, change the base back via the **Edit** button on the PR title β€” this workflow won't re-fire on subsequent edits.`; +} + +// ---- side-effecting main flow (gated to script entry only) ---------------- + +async function fetchPR(owner, repo, prNumber, token) { + return api("GET", `/pulls/${prNumber}`, null, { token, owner, repo }); +} + +async function patchBase(owner, repo, prNumber, newBase, token) { + return api("PATCH", `/pulls/${prNumber}`, { base: newBase }, { token, owner, repo }); +} + +async function hasSentinelComment(owner, repo, prNumber, token) { + const comments = await api("GET", `/issues/${prNumber}/comments?per_page=100`, null, { token, owner, repo }); + return (comments || []).some((c) => typeof c?.body === "string" && c.body.includes(COMMENT_SENTINEL)); +} + +async function postComment(owner, repo, prNumber, body, token) { + return api("POST", `/issues/${prNumber}/comments`, { body }, { token, owner, repo }); +} + +async function main() { + const token = process.env.GITHUB_TOKEN || getInput("github-token", { required: true }); + const prNumber = getInput("pr-number", { required: true }); + const targetBase = getInput("target-base") || "hotfix"; + const headPattern = compilePattern(getInput("hotfix-branch-pattern")); + const dryRun = getBooleanInput("dry-run", false); + let headRef = getInput("head-ref"); + let baseRef = getInput("base-ref"); + let userType = getInput("user-type"); + const { owner, repo } = parseRepo(process.env.GITHUB_REPOSITORY); + + const pr = await fetchPR(owner, repo, prNumber, token); + if (!headRef) headRef = pr?.head?.ref || ""; + if (!baseRef) baseRef = pr?.base?.ref || ""; + if (!userType) userType = pr?.user?.type || ""; + + const skip = shouldSkip({ userType, headRef, baseRef, targetBase, headPattern }); + if (skip.skip) { + console.log(`⏭️ Skipped: ${skip.reason}`); + setOutputs({ redirected: "false", "new-base": baseRef, skipped: "true", "skip-reason": skip.reason }); + return; + } + + console.log(`πŸ”€ Redirecting PR #${prNumber} base: ${baseRef} β†’ ${targetBase}`); + + if (dryRun) { + console.log("ℹ️ dry-run=true β€” skipping PATCH + comment."); + setOutputs({ redirected: "false", "new-base": targetBase, skipped: "false", "skip-reason": "dry-run" }); + return; + } + + await patchBase(owner, repo, prNumber, targetBase, token); + + if (!(await hasSentinelComment(owner, repo, prNumber, token))) { + await postComment(owner, repo, prNumber, buildCommentBody(baseRef, targetBase), token); + console.log("πŸ’¬ Posted explanatory comment (first time)."); + } else { + console.log("πŸ’¬ Sentinel comment already present β€” skipping comment post."); + } + + setOutputs({ redirected: "true", "new-base": targetBase, skipped: "false", "skip-reason": "" }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exit(1); + }); +} diff --git a/.github/actions/github/steps/redirect-hotfix-pr/action.yml b/.github/actions/github/steps/redirect-hotfix-pr/action.yml new file mode 100644 index 00000000..1b889ec2 --- /dev/null +++ b/.github/actions/github/steps/redirect-hotfix-pr/action.yml @@ -0,0 +1,64 @@ +name: "Redirect Hotfix PR" +description: "Change a PR's base branch from the default ('next' under v4) to 'hotfix' when the head branch matches a hotfix/security pattern. Idempotent; skips bot-created PRs and PRs already targeting the hotfix branch." + +inputs: + pr-number: + description: "PR number to inspect / redirect" + required: true + github-token: + description: "GitHub token with PR write access" + required: false + default: ${{ github.token }} + head-ref: + description: "PR head branch. When empty, fetched from API." + required: false + base-ref: + description: "PR base branch. When empty, fetched from API." + required: false + user-type: + description: "PR author's `user.type` ('User'|'Bot'). When empty, fetched from API." + required: false + target-base: + description: "Branch to redirect hotfix/security PRs to." + required: false + default: "hotfix" + hotfix-branch-pattern: + description: "Regex (no anchors needed; matched against the start of the head ref) for branches that should be redirected." + required: false + default: "^(hotfix|security)/" + dry-run: + description: "When 'true', log the would-be redirection but do not PATCH the PR or post a comment." + required: false + default: "false" + +outputs: + redirected: + description: "'true' if the PR base was changed" + value: ${{ steps.redirect.outputs.redirected }} + new-base: + description: "Base branch after the action (possibly unchanged)" + value: ${{ steps.redirect.outputs.new-base }} + skipped: + description: "'true' if the action exited early due to a skip condition" + value: ${{ steps.redirect.outputs.skipped }} + skip-reason: + description: "Human-readable reason when skipped is 'true'" + value: ${{ steps.redirect.outputs.skip-reason }} + +runs: + using: "composite" + steps: + - name: Redirect hotfix PR base + id: redirect + shell: bash + env: + INPUT_PR_NUMBER: ${{ inputs.pr-number }} + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_HEAD_REF: ${{ inputs.head-ref }} + INPUT_BASE_REF: ${{ inputs.base-ref }} + INPUT_USER_TYPE: ${{ inputs.user-type }} + INPUT_TARGET_BASE: ${{ inputs.target-base }} + INPUT_HOTFIX_BRANCH_PATTERN: ${{ inputs.hotfix-branch-pattern }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + run: | + node "${{ github.action_path }}/action.mjs" diff --git a/.github/actions/github/steps/redirect-hotfix-pr/test.mjs b/.github/actions/github/steps/redirect-hotfix-pr/test.mjs new file mode 100644 index 00000000..90386d89 --- /dev/null +++ b/.github/actions/github/steps/redirect-hotfix-pr/test.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * @fileoverview Unit tests for redirect-hotfix-pr pure logic. + * Run: `node test.mjs`. No network β€” only exercises exported functions. + */ + +import { compilePattern, shouldSkip, buildCommentBody, COMMENT_SENTINEL } from "./action.mjs"; + +let failures = 0; + +function eq(actual, expected, label) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { + console.log(` βœ… ${label}`); + } else { + console.error(` ❌ ${label}`); + console.error(` expected: ${JSON.stringify(expected)}`); + console.error(` actual: ${JSON.stringify(actual)}`); + failures++; + } +} + +console.log("compilePattern:"); +const defaultPattern = compilePattern(""); +eq(defaultPattern.test("hotfix/foo"), true, "default pattern matches hotfix/foo"); +eq(defaultPattern.test("security/x"), true, "default pattern matches security/x"); +eq(defaultPattern.test("feat/x"), false, "default pattern rejects feat/x"); +eq(defaultPattern.test("hotfix-foo"), false, "default pattern requires slash after hotfix"); + +const customPattern = compilePattern("^urgent/"); +eq(customPattern.test("urgent/critical"), true, "custom 'urgent/' pattern matches"); +eq(customPattern.test("hotfix/foo"), false, "custom pattern rejects original hotfix/"); + +console.log("\nshouldSkip:"); +const pat = compilePattern(""); +eq( + shouldSkip({ userType: "Bot", headRef: "hotfix/x", baseRef: "next", targetBase: "hotfix", headPattern: pat }), + { skip: true, reason: "PR author is a Bot" }, + "bot β†’ skip" +); +eq( + shouldSkip({ userType: "User", headRef: "feat/x", baseRef: "next", targetBase: "hotfix", headPattern: pat }), + { skip: true, reason: `Head 'feat/x' does not match hotfix pattern ${pat}` }, + "non-hotfix head β†’ skip" +); +eq( + shouldSkip({ userType: "User", headRef: "hotfix/x", baseRef: "hotfix", targetBase: "hotfix", headPattern: pat }), + { skip: true, reason: "PR already targets 'hotfix'" }, + "already on target base β†’ skip" +); +eq( + shouldSkip({ userType: "User", headRef: "hotfix/x", baseRef: "next", targetBase: "hotfix", headPattern: pat }), + { skip: false, reason: "" }, + "hotfix branch, not yet redirected β†’ proceed" +); +eq( + shouldSkip({ userType: "User", headRef: "security/cve-1234", baseRef: "next", targetBase: "hotfix", headPattern: pat }), + { skip: false, reason: "" }, + "security/ branch also redirected" +); +eq( + shouldSkip({ userType: "User", headRef: "", baseRef: "next", targetBase: "hotfix", headPattern: pat }), + { skip: true, reason: `Head '' does not match hotfix pattern ${pat}` }, + "empty head β†’ skip" +); + +console.log("\nbuildCommentBody:"); +const body = buildCommentBody("next", "hotfix"); +eq(body.startsWith(COMMENT_SENTINEL), true, "comment starts with sentinel"); +eq(body.includes("`next`"), true, "comment includes old base"); +eq(body.includes("`hotfix`"), true, "comment includes new base"); + +console.log("\nCOMMENT_SENTINEL:"); +eq(typeof COMMENT_SENTINEL === "string" && COMMENT_SENTINEL.length > 0, true, "sentinel is non-empty"); +eq(COMMENT_SENTINEL !== "_Auto-normalized PR title:_", true, "sentinel differs from normalize-pr-title's sentinel"); + +if (failures > 0) { + console.error(`\n❌ ${failures} test(s) failed`); + process.exit(1); +} +console.log("\nβœ… all tests passed"); From 886de12dfa9974c08472797dd2f3d1752b63bdb7 Mon Sep 17 00:00:00 2001 From: Nate Corcoran Date: Thu, 21 May 2026 13:45:49 -0700 Subject: [PATCH 4/7] =?UTF-8?q?feat(actions):=20add=20force-reset-branch?= =?UTF-8?q?=20foundation=20action=20(v4=20=C2=A711=20PR=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Β§6.3 / Β§7 of the v4 design β€” force-push a source ref onto a target branch via 'git push --force-with-lease' with bounded retry on lease failure. Used by local-next-reset.yml and local-hotfix-reset.yml to bring next/hotfix back to master HEAD after a release lands. action.mjs exports pure helpers for test.mjs: - buildPushArgs: { remote, sourceRef, targetBranch } β†’ argv - isLeaseFailure: stderr classifier β€” matches 'stale info', 'rejected.*force-with-lease', 'fetch first' but NOT auth / missing repo errors (those should propagate, not retry) - parseLsRemoteSha: 'git ls-remote' line β†’ SHA for a given ref Side-effecting main(): 1. git push --force-with-lease master:refs/heads/ 2. On lease failure, fetch + retry up to max-retries times 3. Confirm via ls-remote, emit reset-sha + retries-used outputs Not wired into any workflow yet. --- .../git/steps/force-reset-branch/action.mjs | 129 ++++++++++++++++++ .../git/steps/force-reset-branch/action.yml | 41 ++++++ .../git/steps/force-reset-branch/test.mjs | 89 ++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 .github/actions/git/steps/force-reset-branch/action.mjs create mode 100644 .github/actions/git/steps/force-reset-branch/action.yml create mode 100644 .github/actions/git/steps/force-reset-branch/test.mjs diff --git a/.github/actions/git/steps/force-reset-branch/action.mjs b/.github/actions/git/steps/force-reset-branch/action.mjs new file mode 100644 index 00000000..43900d20 --- /dev/null +++ b/.github/actions/git/steps/force-reset-branch/action.mjs @@ -0,0 +1,129 @@ +/** + * @fileoverview Force-reset a branch via `git push --force-with-lease`, with + * bounded retry on lease failure. Used by Β§6.3 / Β§7 of the v4 design + * (docs/conventions/release-flow-v4.md) to keep next/hotfix in sync with + * master HEAD after release merges. + * + * Pure helpers are exported for test.mjs; the side-effecting main is gated + * to script entry. + * + * @module @cldmv/.github.git.steps.force-reset-branch + */ + +import { execSync } from "node:child_process"; +import { getInput, setOutputs } from "../../../common/common/core.mjs"; + +/** + * Build the argv for the force-push command. Kept pure so the test can + * verify shape without invoking git. + * + * @public + * @param {object} args + * @param {string} args.remote + * @param {string} args.sourceRef + * @param {string} args.targetBranch + * @returns {string[]} argv for execFile/spawn (or join(' ') for execSync) + */ +export function buildPushArgs({ remote, sourceRef, targetBranch }) { + return ["push", remote, `${sourceRef}:refs/heads/${targetBranch}`, "--force-with-lease"]; +} + +/** + * Decide whether a captured stderr looks like a `--force-with-lease` + * rejection (vs. a different failure mode). Retrying only makes sense for + * lease failures β€” auth errors, missing refs, etc. should propagate. + * + * The phrases below come from git's actual reject messages; both English + * variants are matched. + * + * @public + * @param {string} stderr + * @returns {boolean} + */ +export function isLeaseFailure(stderr) { + if (typeof stderr !== "string") return false; + return /stale info|rejected.*force-with-lease|fetch first/i.test(stderr); +} + +/** + * Parse the `\t` line(s) emitted by `git ls-remote` and return the + * SHA matching the given ref (full or short). Returns "" when not found. + * + * @public + * @param {string} lsRemoteOutput + * @param {string} ref - e.g. "refs/heads/next" or "next" + * @returns {string} + */ +export function parseLsRemoteSha(lsRemoteOutput, ref) { + if (typeof lsRemoteOutput !== "string" || !ref) return ""; + const target = ref.startsWith("refs/") ? ref : `refs/heads/${ref}`; + for (const line of lsRemoteOutput.split("\n")) { + const [sha, refName] = line.split("\t").map((s) => s?.trim()); + if (refName === target) return sha || ""; + } + return ""; +} + +// ---- side-effecting main flow (gated to script entry only) ---------------- + +function run(cmd) { + return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +function runCapturingStderr(cmd) { + try { + const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + return { ok: true, stdout, stderr: "" }; + } catch (e) { + return { ok: false, stdout: e.stdout?.toString() || "", stderr: e.stderr?.toString() || e.message }; + } +} + +async function main() { + const targetBranch = getInput("target-branch", { required: true }); + const sourceRef = getInput("source-ref") || "master"; + const remote = getInput("remote") || "origin"; + const maxRetries = Math.max(0, parseInt(getInput("max-retries") || "1", 10) || 0); + + const pushArgs = buildPushArgs({ remote, sourceRef, targetBranch }); + const pushCmd = `git ${pushArgs.join(" ")}`; + console.log(`▢️ ${pushCmd}`); + + let attempts = 0; + let result = runCapturingStderr(pushCmd); + + while (!result.ok && attempts < maxRetries && isLeaseFailure(result.stderr)) { + attempts++; + console.log(`⚠️ Lease failure on attempt ${attempts}:`); + console.log(result.stderr.trim()); + console.log(`πŸ”„ Fetching ${remote} ${targetBranch} and retrying…`); + try { + run(`git fetch ${remote} ${targetBranch} --quiet`); + } catch (e) { + // Fetch failure is reported but doesn't preempt the retry β€” push will + // fail again with a clearer message if it's genuinely broken. + console.log(`(non-fatal) git fetch failed: ${e.message}`); + } + result = runCapturingStderr(pushCmd); + } + + if (!result.ok) { + console.error(`::error::Force-reset of '${targetBranch}' failed after ${attempts} retry attempt(s)`); + console.error(result.stderr.trim()); + process.exit(1); + } + + // Read back the new remote SHA so the caller has it for summary/output. + const lsOut = run(`git ls-remote ${remote} refs/heads/${targetBranch}`); + const resetSha = parseLsRemoteSha(lsOut, targetBranch); + + console.log(`βœ… Force-reset complete. ${targetBranch} β†’ ${resetSha || "(could not verify SHA)"}`); + setOutputs({ "reset-sha": resetSha, "retries-used": String(attempts) }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exit(1); + }); +} diff --git a/.github/actions/git/steps/force-reset-branch/action.yml b/.github/actions/git/steps/force-reset-branch/action.yml new file mode 100644 index 00000000..99730290 --- /dev/null +++ b/.github/actions/git/steps/force-reset-branch/action.yml @@ -0,0 +1,41 @@ +name: "Force-Reset Branch" +description: "Force-reset a target branch to a source ref via `git push --force-with-lease`. Used by v4's local-next-reset.yml and local-hotfix-reset.yml (Β§6.3 / Β§7) to bring next/hotfix back to master HEAD after a release lands. Retries once on lease failure (re-fetches then retries) so a concurrent push from the bot itself doesn't race the reset." + +inputs: + target-branch: + description: "Branch to reset (e.g., 'next', 'hotfix'). The local tip of source-ref will be force-pushed to this branch on origin." + required: true + source-ref: + description: "Ref to reset from. Defaults to 'master' β€” the typical v4 case is bringing next/hotfix back to master HEAD." + required: false + default: "master" + remote: + description: "Git remote name." + required: false + default: "origin" + max-retries: + description: "Number of times to retry on lease failure (re-fetch + push). 0 disables retry." + required: false + default: "1" + +outputs: + reset-sha: + description: "SHA the target branch now points to on the remote." + value: ${{ steps.reset.outputs.reset-sha }} + retries-used: + description: "How many retries were needed (0 on first-try success)." + value: ${{ steps.reset.outputs.retries-used }} + +runs: + using: "composite" + steps: + - name: Force-reset branch + id: reset + shell: bash + env: + INPUT_TARGET_BRANCH: ${{ inputs.target-branch }} + INPUT_SOURCE_REF: ${{ inputs.source-ref }} + INPUT_REMOTE: ${{ inputs.remote }} + INPUT_MAX_RETRIES: ${{ inputs.max-retries }} + run: | + node "${{ github.action_path }}/action.mjs" diff --git a/.github/actions/git/steps/force-reset-branch/test.mjs b/.github/actions/git/steps/force-reset-branch/test.mjs new file mode 100644 index 00000000..388eb5e4 --- /dev/null +++ b/.github/actions/git/steps/force-reset-branch/test.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * @fileoverview Unit tests for force-reset-branch pure logic. + * Run: `node test.mjs`. Does not invoke git. + */ + +import { buildPushArgs, isLeaseFailure, parseLsRemoteSha } from "./action.mjs"; + +let failures = 0; + +function eq(actual, expected, label) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { + console.log(` βœ… ${label}`); + } else { + console.error(` ❌ ${label}`); + console.error(` expected: ${JSON.stringify(expected)}`); + console.error(` actual: ${JSON.stringify(actual)}`); + failures++; + } +} + +console.log("buildPushArgs:"); +eq( + buildPushArgs({ remote: "origin", sourceRef: "master", targetBranch: "next" }), + ["push", "origin", "master:refs/heads/next", "--force-with-lease"], + "master β†’ next" +); +eq( + buildPushArgs({ remote: "upstream", sourceRef: "abc123", targetBranch: "hotfix" }), + ["push", "upstream", "abc123:refs/heads/hotfix", "--force-with-lease"], + "upstream remote + SHA source" +); + +console.log("\nisLeaseFailure:"); +eq( + isLeaseFailure(" ! [rejected] master -> refs/heads/next (stale info)\n"), + true, + "'stale info' classic lease rejection" +); +eq( + isLeaseFailure("hint: failed to push some refs ... fetch first"), + true, + "'fetch first' counts as lease-style" +); +eq( + isLeaseFailure("Rejected: --force-with-lease check failed"), + true, + "--force-with-lease in message" +); +eq( + isLeaseFailure("Permission denied (publickey)"), + false, + "auth error not a lease failure" +); +eq( + isLeaseFailure("fatal: remote: Repository not found"), + false, + "missing-repo not a lease failure" +); +eq(isLeaseFailure(""), false, "empty string"); +eq(isLeaseFailure(null), false, "null input"); +eq(isLeaseFailure(undefined), false, "undefined input"); + +console.log("\nparseLsRemoteSha:"); +const sample = "deadbeef1234567890123456789012345678abcd\trefs/heads/next\nfeedface1234567890123456789012345678abcd\trefs/heads/master\n"; +eq( + parseLsRemoteSha(sample, "refs/heads/next"), + "deadbeef1234567890123456789012345678abcd", + "full ref match" +); +eq( + parseLsRemoteSha(sample, "master"), + "feedface1234567890123456789012345678abcd", + "short name β†’ full ref auto-resolved" +); +eq( + parseLsRemoteSha(sample, "refs/heads/missing"), + "", + "missing ref β†’ empty string" +); +eq(parseLsRemoteSha("", "next"), "", "empty output"); +eq(parseLsRemoteSha(sample, ""), "", "empty ref"); + +if (failures > 0) { + console.error(`\n❌ ${failures} test(s) failed`); + process.exit(1); +} +console.log("\nβœ… all tests passed"); From 9947bb577580b71233f4ee2e0ac7ebc4b4e8cea8 Mon Sep 17 00:00:00 2001 From: Nate Corcoran Date: Thu, 21 May 2026 13:47:17 -0700 Subject: [PATCH 5/7] =?UTF-8?q?feat(actions):=20add=20merge-master-into-br?= =?UTF-8?q?anch=20foundation=20action=20(v4=20=C2=A711=20PR=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Β§7.2 of the v4 design β€” API-driven merge of master into a target branch via POST /repos/{owner}/{repo}/merges. Used after a hotfix lands on master to bring next's feature work back in sync without losing the accumulated work that a force-reset would discard. action.mjs exports pure helpers for test.mjs: - buildMergePayload: { targetBranch, sourceRef, commitMessage } β†’ Merges API body. Falls back to a sensible default message when commit-message is empty / whitespace. - interpretMergeResponse: classify response into { performed, sha, conflict, error }. Handles 201/204/409 and any other status as an error with the original status preserved. Side-effecting main() does a raw fetch (not the shared api() helper) because that helper throws on any non-2xx β€” and 204 'already up-to- date' is a legitimate success case here, while 409 'merge conflict' needs a structured failure (sets had-conflict=true output AND exits 1). Not wired into any workflow yet. --- .../steps/merge-master-into-branch/action.mjs | 122 ++++++++++++++++++ .../steps/merge-master-into-branch/action.yml | 48 +++++++ .../steps/merge-master-into-branch/test.mjs | 79 ++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 .github/actions/github/steps/merge-master-into-branch/action.mjs create mode 100644 .github/actions/github/steps/merge-master-into-branch/action.yml create mode 100644 .github/actions/github/steps/merge-master-into-branch/test.mjs diff --git a/.github/actions/github/steps/merge-master-into-branch/action.mjs b/.github/actions/github/steps/merge-master-into-branch/action.mjs new file mode 100644 index 00000000..27fb0857 --- /dev/null +++ b/.github/actions/github/steps/merge-master-into-branch/action.mjs @@ -0,0 +1,122 @@ +/** + * @fileoverview Merge a source ref into a target branch via the GitHub + * Merges API. Implements Β§7.2 of the v4 design β€” after a hotfix lands on + * master, run this to preserve accumulated feature work on `next`. + * + * Pure helpers are exported for test.mjs; side-effecting main is gated to + * script entry. + * + * @module @cldmv/.github.github.steps.merge-master-into-branch + */ + +import { parseRepo } from "../../api/_api/core.mjs"; +import { getInput, setOutputs, getBooleanInput } from "../../../common/common/core.mjs"; + +/** + * Build the JSON body for `POST /repos/{owner}/{repo}/merges`. + * + * @public + * @param {object} args + * @param {string} args.targetBranch + * @param {string} args.sourceRef + * @param {string} [args.commitMessage] - Override commit message. + * @returns {{ base: string, head: string, commit_message: string }} + */ +export function buildMergePayload({ targetBranch, sourceRef, commitMessage }) { + const message = commitMessage && commitMessage.trim() + ? commitMessage.trim() + : `Merge ${sourceRef} into ${targetBranch}`; + return { base: targetBranch, head: sourceRef, commit_message: message }; +} + +/** + * Interpret a Merges API response. GitHub defines three relevant outcomes: + * - 201 Created: merge happened; body has { sha, ... } + * - 204 No Content: nothing to merge (already up-to-date) + * - 409 Conflict: merge conflict; we treat this as a failure + * + * 4xx outcomes other than 409 (404 missing branch, 422 bad ref, etc.) are + * reported as failures with the original status preserved. + * + * @public + * @param {number} status - HTTP status code. + * @param {object|null} body - Parsed JSON body (null on 204). + * @returns {{ performed: boolean, sha: string, conflict: boolean, error: string }} + */ +export function interpretMergeResponse(status, body) { + if (status === 201) { + return { performed: true, sha: body?.sha || "", conflict: false, error: "" }; + } + if (status === 204) { + return { performed: false, sha: "", conflict: false, error: "" }; + } + if (status === 409) { + return { performed: false, sha: "", conflict: true, error: "Merge conflict (409) β€” manual resolution required" }; + } + const detail = body && typeof body === "object" ? JSON.stringify(body) : String(body || ""); + return { performed: false, sha: "", conflict: false, error: `Unexpected status ${status}: ${detail}` }; +} + +// ---- side-effecting main flow (gated to script entry only) ---------------- + +async function callMergesApi({ owner, repo, payload, token }) { + const url = `https://api.github.com/repos/${owner}/${repo}/merges`; + const res = await fetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10" + }, + body: JSON.stringify(payload) + }); + const status = res.status; + const body = status === 204 ? null : await res.json().catch(() => null); + return { status, body }; +} + +async function main() { + const token = process.env.GITHUB_TOKEN || getInput("github-token", { required: true }); + const targetBranch = getInput("target-branch", { required: true }); + const sourceRef = getInput("source-ref") || "master"; + const commitMessage = getInput("commit-message"); + const dryRun = getBooleanInput("dry-run", false); + const { owner, repo } = parseRepo(process.env.GITHUB_REPOSITORY); + + const payload = buildMergePayload({ targetBranch, sourceRef, commitMessage }); + console.log(`▢️ POST /repos/${owner}/${repo}/merges β€” merging '${sourceRef}' into '${targetBranch}'`); + console.log(` commit_message: "${payload.commit_message}"`); + + if (dryRun) { + console.log("ℹ️ dry-run=true β€” skipping API call."); + setOutputs({ "merge-sha": "", "merge-performed": "false", "had-conflict": "false" }); + return; + } + + const { status, body } = await callMergesApi({ owner, repo, payload, token }); + const result = interpretMergeResponse(status, body); + + setOutputs({ + "merge-sha": result.sha, + "merge-performed": String(result.performed), + "had-conflict": String(result.conflict) + }); + + if (result.error) { + console.error(`::error::${result.error}`); + process.exit(1); + } + + if (result.performed) { + console.log(`βœ… Merge commit created: ${result.sha}`); + } else { + console.log(`ℹ️ Already up-to-date β€” nothing to merge.`); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exit(1); + }); +} diff --git a/.github/actions/github/steps/merge-master-into-branch/action.yml b/.github/actions/github/steps/merge-master-into-branch/action.yml new file mode 100644 index 00000000..98cc12e3 --- /dev/null +++ b/.github/actions/github/steps/merge-master-into-branch/action.yml @@ -0,0 +1,48 @@ +name: "Merge Master Into Branch" +description: "Merge master (or another source ref) into a target branch via the GitHub Merges API. Used by Β§7.2 of the v4 design: after a hotfix lands on master, merge master into next so accumulated feature work is preserved (rather than force-reset and lost). Handles the three GitHub-defined outcomes: 201 created merge commit, 204 already-up-to-date, 409 merge conflict." + +inputs: + target-branch: + description: "Branch to receive the merge (the base of the API call). E.g., 'next'." + required: true + source-ref: + description: "Ref being merged in (the head of the API call)." + required: false + default: "master" + github-token: + description: "GitHub token with write access to the target branch." + required: false + default: ${{ github.token }} + commit-message: + description: "Override merge commit message. Default is 'Merge into '." + required: false + dry-run: + description: "When 'true', log what would happen but do not make the API call." + required: false + default: "false" + +outputs: + merge-sha: + description: "SHA of the new merge commit (201). Empty when nothing was merged (204)." + value: ${{ steps.merge.outputs.merge-sha }} + merge-performed: + description: "'true' when a merge commit was created; 'false' on no-op (204 already up-to-date)." + value: ${{ steps.merge.outputs.merge-performed }} + had-conflict: + description: "'true' when the API returned 409 (merge conflict). On conflict, the step also fails." + value: ${{ steps.merge.outputs.had-conflict }} + +runs: + using: "composite" + steps: + - name: Merge via GitHub API + id: merge + shell: bash + env: + INPUT_TARGET_BRANCH: ${{ inputs.target-branch }} + INPUT_SOURCE_REF: ${{ inputs.source-ref }} + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_COMMIT_MESSAGE: ${{ inputs.commit-message }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + run: | + node "${{ github.action_path }}/action.mjs" diff --git a/.github/actions/github/steps/merge-master-into-branch/test.mjs b/.github/actions/github/steps/merge-master-into-branch/test.mjs new file mode 100644 index 00000000..4277b6e2 --- /dev/null +++ b/.github/actions/github/steps/merge-master-into-branch/test.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * @fileoverview Unit tests for merge-master-into-branch pure logic. + * Run: `node test.mjs`. No network β€” only exercises exported functions. + */ + +import { buildMergePayload, interpretMergeResponse } from "./action.mjs"; + +let failures = 0; + +function eq(actual, expected, label) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { + console.log(` βœ… ${label}`); + } else { + console.error(` ❌ ${label}`); + console.error(` expected: ${JSON.stringify(expected)}`); + console.error(` actual: ${JSON.stringify(actual)}`); + failures++; + } +} + +console.log("buildMergePayload:"); +eq( + buildMergePayload({ targetBranch: "next", sourceRef: "master" }), + { base: "next", head: "master", commit_message: "Merge master into next" }, + "default message uses source/target names" +); +eq( + buildMergePayload({ targetBranch: "next", sourceRef: "master", commitMessage: "" }), + { base: "next", head: "master", commit_message: "Merge master into next" }, + "empty message falls back to default" +); +eq( + buildMergePayload({ targetBranch: "next", sourceRef: "master", commitMessage: " " }), + { base: "next", head: "master", commit_message: "Merge master into next" }, + "whitespace-only message falls back to default" +); +eq( + buildMergePayload({ targetBranch: "next", sourceRef: "master", commitMessage: "Custom msg" }), + { base: "next", head: "master", commit_message: "Custom msg" }, + "custom message used" +); + +console.log("\ninterpretMergeResponse:"); +eq( + interpretMergeResponse(201, { sha: "abc123" }), + { performed: true, sha: "abc123", conflict: false, error: "" }, + "201 with sha β†’ performed=true" +); +eq( + interpretMergeResponse(201, {}), + { performed: true, sha: "", conflict: false, error: "" }, + "201 without sha β†’ still performed=true, empty sha" +); +eq( + interpretMergeResponse(204, null), + { performed: false, sha: "", conflict: false, error: "" }, + "204 (no content) β†’ already up-to-date" +); +eq( + interpretMergeResponse(409, { message: "Merge conflict" }), + { performed: false, sha: "", conflict: true, error: "Merge conflict (409) β€” manual resolution required" }, + "409 β†’ conflict=true, error set" +); +const r404 = interpretMergeResponse(404, { message: "Not Found" }); +eq(r404.performed, false, "404 β†’ not performed"); +eq(r404.conflict, false, "404 β†’ not a conflict"); +eq(r404.error.includes("404"), true, "404 error mentions status"); +const r422 = interpretMergeResponse(422, { message: "Validation failed" }); +eq(r422.error.includes("422"), true, "422 error mentions status"); +const r500 = interpretMergeResponse(500, { message: "boom" }); +eq(r500.error.includes("500"), true, "500 error mentions status"); + +if (failures > 0) { + console.error(`\n❌ ${failures} test(s) failed`); + process.exit(1); +} +console.log("\nβœ… all tests passed"); From 6533059af1b13ce73a0ea555c4e510262ca24481 Mon Sep 17 00:00:00 2001 From: Nate Corcoran Date: Thu, 21 May 2026 14:44:05 -0700 Subject: [PATCH 6/7] docs(v4): de-link PR references in release-flow-v4.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub auto-links a hash followed by a number (in commit messages, PR/issue bodies, and comments) to the issue/PR with that number. The design doc used 'PR #N'-style references for the Β§11 migration sequence plus one observed-bug reference, which would create spurious cross-links. Rewrite them to the hashless form ('PR 1', 'PRs 3 through 5', 'bug 1'). The Β§11 table already used the bare '| N |' cell format, so only prose references needed changing. No semantic change. --- docs/conventions/release-flow-v4.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/conventions/release-flow-v4.md b/docs/conventions/release-flow-v4.md index 0fd0aa3d..9f4dde37 100644 --- a/docs/conventions/release-flow-v4.md +++ b/docs/conventions/release-flow-v4.md @@ -18,7 +18,7 @@ Real-world friction observed across the v3 cut: - **Cascade churn.** Three approved patch PRs that should ship as one v3.2.4 ended up as v3.2.4 / v3.2.5 / v3.2.6 β€” three separate releases for what was conceptually one batch. - **Race conditions.** `update-major-version-tags` and `sync-fan-out` fire in parallel; if sync resolves `@v3` first, it runs against the previous release's action code (see commit `608f621`). -- **State drift.** The persistent release PR's title/body update path silently bypassed the version-bump path when target was "already bumped on branch" β€” observable bug #1. +- **State drift.** The persistent release PR's title/body update path silently bypassed the version-bump path when target was "already bumped on branch" β€” observable bug 1. - **Discovery brittleness.** Any change to release-PR title format (e.g., adding `- ` suffix) silently broke the regex sync uses to find them. The common thread: **per-PR release PRs encode too much state on each contributor's branch**, and that state diverges in messy ways when more than one PR is open. @@ -373,7 +373,7 @@ Repo-level "Allow auto-merge" toggle is **ON** (enabled by the bootstrap workflo - **Idempotent:** running twice should be a no-op. - **Reversible:** does NOT delete existing v3 workflows. Repo can run v3 and v4 in parallel until ready to fully cut over. - + ## 11. Migration plan @@ -390,9 +390,9 @@ Six PRs in sequence, each independently shippable: Each step ships against `@v4` (rolling major tag). CLDMV repos cut over individually by swapping their workflow files from `@v3` to `@v4` references β€” older example files remain in git history for reference. `@v3` stays as an immutable tag indefinitely; not actively maintained after v4.0.0. -**`@v4` stability between migration PRs:** PRs #3 through #5 are additive but incomplete β€” `@v4` during that window is an unstable preview. Do not migrate production consumer repos until PR #6 lands and v4.0.0 is formally cut. After PR #6, `@v4` is considered stable. +**`@v4` stability between migration PRs:** PRs 3 through 5 are additive but incomplete β€” `@v4` during that window is an unstable preview. Do not migrate production consumer repos until PR 6 lands and v4.0.0 is formally cut. After PR 6, `@v4` is considered stable. -Migration doc (`docs/migration/v3-to-v4.md`) is written **all at once** as the final step of PR #6 β€” written for internal institutional memory, not external consumer hand-holding. +Migration doc (`docs/migration/v3-to-v4.md`) is written **all at once** as the final step of PR 6 β€” written for internal institutional memory, not external consumer hand-holding. ## 12. Out of scope (deferred) From 58c8a5300434093309dcbaaf20ecf3cfd3abbefa Mon Sep 17 00:00:00 2001 From: "cldmv-bot[bot]" <230771808+cldmv-bot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 22:11:53 +0000 Subject: [PATCH 7/7] chore: bump version to 3.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a850502..cbb07adb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/.github", - "version": "3.2.5", + "version": "3.3.0", "description": "Shared GitHub Actions, reusable workflows, and org-wide tooling for the CLDMV organization.", "author": { "name": "Shinrai",