From d7a9457dc4356940554ee29e6632ceebcbd2adcf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 15 Jun 2026 13:40:59 +0000 Subject: [PATCH] ci: fix release workflow dispatches --- .github/workflows/pr-title.yaml | 42 ++++++++++++++++++++ .github/workflows/release-please.yaml | 51 ++++++++++++++++++++---- .github/workflows/test.yml | 8 ++++ scripts/package-lock.json | 17 ++++++++ scripts/package.json | 1 + scripts/release-please-runner.js | 56 +++++++++++++++++++++++---- scripts/test-release-please-runner.js | 38 +++++++++++++++++- 7 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/pr-title.yaml diff --git a/.github/workflows/pr-title.yaml b/.github/workflows/pr-title.yaml new file mode 100644 index 00000000..f772ac70 --- /dev/null +++ b/.github/workflows/pr-title.yaml @@ -0,0 +1,42 @@ +name: PR Title + +on: + workflow_dispatch: + inputs: + title: + description: PR title to validate + required: true + type: string + pull_request: + types: + - opened + - edited + - reopened + - ready_for_review + - synchronize + +permissions: {} + +jobs: + conventional-title: + runs-on: ubuntu-latest + steps: + - name: Validate PR title + env: + PR_TITLE: ${{ github.event_name == 'workflow_dispatch' && inputs.title || github.event.pull_request.title }} + run: | + set -euo pipefail + node <<'NODE' + const title = process.env.PR_TITLE ?? ""; + const conventionalTitle = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+$/; + + if (conventionalTitle.test(title)) { + console.log(`PR title is a Conventional Commit: ${title}`); + process.exit(0); + } + + console.error("PR title must be a Conventional Commit title."); + console.error(`Got: ${title}`); + console.error("Expected examples: feat: add health check, fix(diff): close stale tabs, chore(release): 0.4.0"); + process.exit(1); + NODE diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 324c5e19..db46bf63 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -32,6 +32,7 @@ jobs: outputs: prs_created: ${{ steps.release_please.outputs.prs_created }} pr_branches: ${{ steps.release_please.outputs.pr_branches }} + pr_metadata: ${{ steps.release_please.outputs.pr_metadata }} releases_created: ${{ steps.release_please.outputs.releases_created }} release_tags: ${{ steps.release_please.outputs.release_tags }} steps: @@ -76,7 +77,6 @@ jobs: if: ${{ always() && !cancelled() && needs.release-please.outputs.releases_created == 'true' }} permissions: actions: write - contents: read steps: - name: Dispatch Communique release notes for created releases env: @@ -86,7 +86,7 @@ jobs: set -euo pipefail read -ra tags <<< "$RELEASE_TAGS" for tag in "${tags[@]}"; do - gh workflow run release-notes.yaml --ref "$tag" --field "tag=$tag" + gh workflow run release-notes.yaml --repo "$GITHUB_REPOSITORY" --ref "$tag" --field "tag=$tag" done # Branch pushes made with the workflow token do not trigger `pull_request` @@ -97,15 +97,50 @@ jobs: if: ${{ always() && !cancelled() && needs.release-please.outputs.prs_created == 'true' }} permissions: actions: write - contents: read steps: - name: Dispatch checks onto the release PR branch env: GH_TOKEN: ${{ github.token }} - PR_BRANCHES: ${{ needs.release-please.outputs.pr_branches }} + PR_METADATA: ${{ needs.release-please.outputs.pr_metadata }} run: | set -euo pipefail - read -ra branches <<< "$PR_BRANCHES" - for branch in "${branches[@]}"; do - gh workflow run test.yml --ref "$branch" - done + node <<'NODE' + const { spawnSync } = require("node:child_process"); + + const repository = process.env.GITHUB_REPOSITORY; + const prMetadata = JSON.parse(process.env.PR_METADATA || "[]"); + if (typeof repository !== "string" || repository === "") { + throw new Error("GITHUB_REPOSITORY is required"); + } + if (!Array.isArray(prMetadata) || prMetadata.length === 0) { + throw new Error("release PR metadata is required"); + } + + function runGh(args) { + const result = spawnSync("gh", args, { stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`gh ${args.join(" ")} failed with status ${result.status}`); + } + } + + for (const pr of prMetadata) { + if (typeof pr.branch !== "string" || pr.branch === "") { + throw new Error("release PR branch is required"); + } + if (typeof pr.title !== "string" || pr.title === "") { + throw new Error(`release PR title is required for branch ${pr.branch}`); + } + runGh([ + "workflow", + "run", + "pr-title.yaml", + "--repo", + repository, + "--ref", + pr.branch, + "--raw-field", + `title=${pr.title}`, + ]); + runGh(["workflow", "run", "test.yml", "--repo", repository, "--ref", pr.branch]); + } + NODE diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8375d2d..9a8e6499 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,14 @@ jobs: - name: Set up mise uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + - name: Install release tooling dependencies + env: + NPM_CONFIG_IGNORE_SCRIPTS: "true" + run: npm ci --prefix scripts + + - name: Run release tooling tests + run: npm --prefix scripts run test:release-please-runner + # Cache the rock tree (rebuilt from luarocks.org otherwise); a miss just rebuilds. - name: Cache Lua test rocks uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 9e1341ec..b703cd6c 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "claudecode.nvim-release-tools", "devDependencies": { + "prettier": "3.8.4", "release-please": "17.9.0" } }, @@ -1408,6 +1409,22 @@ "dev": true, "license": "ISC" }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", diff --git a/scripts/package.json b/scripts/package.json index 1fda21f9..e03ec9d7 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -2,6 +2,7 @@ "name": "claudecode.nvim-release-tools", "private": true, "devDependencies": { + "prettier": "3.8.4", "release-please": "17.9.0" }, "scripts": { diff --git a/scripts/release-please-runner.js b/scripts/release-please-runner.js index ffeb4d86..e8dd666f 100755 --- a/scripts/release-please-runner.js +++ b/scripts/release-please-runner.js @@ -11,7 +11,7 @@ */ const assert = require("node:assert/strict"); -const { execFile } = require("node:child_process"); +const { execFile, execFileSync } = require("node:child_process"); const { appendFileSync, existsSync, @@ -36,6 +36,13 @@ const { const { Simple } = require("release-please/build/src/strategies/simple.js"); const { Changelog } = require("release-please/build/src/updaters/changelog.js"); +const LOCAL_PRETTIER_BIN = join( + __dirname, + "node_modules", + ".bin", + process.platform === "win32" ? "prettier.cmd" : "prettier", +); + const execFileAsync = promisify(execFile); const UNRELEASED_HEADING_PATTERN = /^#{2,3} \[?Unreleased\]?[ \t]*$/; @@ -243,6 +250,29 @@ function findLineOutsideFences(lines, start, predicate) { return -1; } +function resolvePrettierBin(env = process.env, fileExists = existsSync) { + if (env.PRETTIER_BIN !== undefined && env.PRETTIER_BIN !== "") { + return env.PRETTIER_BIN; + } + if (fileExists(LOCAL_PRETTIER_BIN)) { + return LOCAL_PRETTIER_BIN; + } + return "prettier"; +} + +function formatChangelogMarkdown(content) { + assert.equal(typeof content, "string", "content must be a string"); + return execFileSync( + resolvePrettierBin(), + ["--stdin-filepath", "CHANGELOG.md"], + { + encoding: "utf8", + input: content, + maxBuffer: 16 * 1024 * 1024, + }, + ); +} + /** * Replaces release-please's stock CHANGELOG updater. It keeps `## [Unreleased]` * at the top, clears its draft body after Communique reconciles it into the @@ -280,7 +310,7 @@ class UnreleasedAwareChangelog { (line) => NEXT_SECTION_PATTERN.test(line), ); const rest = nextIndex === -1 ? "" : lines.slice(nextIndex).join("\n"); - return joinSections(head, entry, rest); + return formatChangelogMarkdown(joinSections(head, entry, rest)); } // Self-heal a missing Unreleased anchor so Communique has draft space on @@ -290,9 +320,13 @@ class UnreleasedAwareChangelog { ); if (titleIndex !== -1) { const head = `${lines.slice(0, titleIndex + 1).join("\n")}\n\n## [Unreleased]`; - return joinSections(head, entry, lines.slice(titleIndex + 1).join("\n")); + return formatChangelogMarkdown( + joinSections(head, entry, lines.slice(titleIndex + 1).join("\n")), + ); } - return joinSections("# Changelog\n\n## [Unreleased]", entry, existing); + return formatChangelogMarkdown( + joinSections("# Changelog\n\n## [Unreleased]", entry, existing), + ); } } @@ -337,12 +371,16 @@ function formatReleaseOutputs(releases) { function formatPullRequestOutputs(pullRequests) { assert.ok(Array.isArray(pullRequests), "pullRequests must be an array"); - const branches = pullRequests + const prs = pullRequests .filter((pullRequest) => pullRequest !== undefined) - .map((pullRequest) => pullRequest.headBranchName); + .map((pullRequest) => ({ + branch: pullRequest.headBranchName, + title: pullRequest.title.toString(), + })); return { - prs_created: branches.length > 0 ? "true" : "false", - pr_branches: branches.join(" "), + prs_created: prs.length > 0 ? "true" : "false", + pr_branches: prs.map((pr) => pr.branch).join(" "), + pr_metadata: JSON.stringify(prs), }; } @@ -466,10 +504,12 @@ module.exports = { buildCommuniqueArgs, createCommuniqueChangelogNotes, findLineOutsideFences, + formatChangelogMarkdown, formatChangelogSection, formatPullRequestOutputs, formatReleaseOutputs, normalizeCommuniqueBody, + resolvePrettierBin, releasePleaseNotesAreEmpty, todayIsoDate, }; diff --git a/scripts/test-release-please-runner.js b/scripts/test-release-please-runner.js index 1d472ee6..d5a14944 100755 --- a/scripts/test-release-please-runner.js +++ b/scripts/test-release-please-runner.js @@ -28,6 +28,17 @@ function buildOptions() { }; } +function testPrettierResolution() { + assert.equal( + runner.resolvePrettierBin({ PRETTIER_BIN: "/tmp/prettier" }), + "/tmp/prettier", + ); + assert.equal( + runner.resolvePrettierBin({}, () => false), + "prettier", + ); +} + async function testCommuniqueArgs() { assert.deepEqual( runner.buildCommuniqueArgs({ @@ -66,7 +77,7 @@ async function testHeadingNormalization() { async function testUnreleasedUpdater() { const updated = new runner.UnreleasedAwareChangelog({ - changelogEntry: "## [0.4.0] - 2026-06-15\n\n- Added x", + changelogEntry: "## [0.4.0] - 2026-06-15\n\n* Added x", }).updateContent( "# Changelog\n\n## [Unreleased]\n\n### Features\n\n- Draft\n\n## [0.3.0] - 2025-09-15\n\n- Previous\n", ); @@ -74,6 +85,29 @@ async function testUnreleasedUpdater() { updated, "# Changelog\n\n## [Unreleased]\n\n## [0.4.0] - 2026-06-15\n\n- Added x\n\n## [0.3.0] - 2025-09-15\n\n- Previous\n", ); + assert.equal(updated, runner.formatChangelogMarkdown(updated)); +} + +function testPullRequestOutputs() { + assert.deepEqual(runner.formatPullRequestOutputs([]), { + prs_created: "false", + pr_branches: "", + pr_metadata: "[]", + }); + assert.deepEqual( + runner.formatPullRequestOutputs([ + { + headBranchName: "release-please--branches--main", + title: { toString: () => "chore(release): 0.4.0" }, + }, + ]), + { + prs_created: "true", + pr_branches: "release-please--branches--main", + pr_metadata: + '[{"branch":"release-please--branches--main","title":"chore(release): 0.4.0"}]', + }, + ); } async function testInternalCommitSkipsCommunique() { @@ -111,9 +145,11 @@ async function testReleasableCommitRunsCommunique() { } async function main() { + testPrettierResolution(); await testCommuniqueArgs(); await testHeadingNormalization(); await testUnreleasedUpdater(); + testPullRequestOutputs(); await testInternalCommitSkipsCommunique(); await testReleasableCommitRunsCommunique(); }