Skip to content

Commit 7644451

Browse files
authored
ci: fix release workflow dispatches (#280)
## Summary - Fix release workflow dispatch jobs by passing `--repo \"$GITHUB_REPOSITORY\"` so `gh workflow run` does not require a checked-out git repository. - Add a lightweight PR title workflow that enforces Conventional Commit-style PR titles for better Release Please parsing. ## Validation - `mise x prettier@3.8.4 -- prettier --write .github/workflows/release-please.yaml .github/workflows/pr-title.yaml` - `mise x actionlint@1.7.12 -- actionlint .github/workflows/release-please.yaml .github/workflows/release-notes.yaml .github/workflows/test.yml .github/workflows/pr-title.yaml` - `mise x zizmor@1.25.2 -- zizmor --offline .github/workflows/release-please.yaml .github/workflows/release-notes.yaml .github/workflows/test.yml .github/workflows/pr-title.yaml` - `treefmt --ci` - `git diff --check` - Local regex smoke test for valid/invalid PR titles --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `openai:gpt-5.5` • Thinking: `xhigh`_
1 parent 9ecf9f3 commit 7644451

7 files changed

Lines changed: 196 additions & 17 deletions

File tree

.github/workflows/pr-title.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: PR Title
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
title:
7+
description: PR title to validate
8+
required: true
9+
type: string
10+
pull_request:
11+
types:
12+
- opened
13+
- edited
14+
- reopened
15+
- ready_for_review
16+
- synchronize
17+
18+
permissions: {}
19+
20+
jobs:
21+
conventional-title:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- name: Validate PR title
25+
env:
26+
PR_TITLE: ${{ github.event_name == 'workflow_dispatch' && inputs.title || github.event.pull_request.title }}
27+
run: |
28+
set -euo pipefail
29+
node <<'NODE'
30+
const title = process.env.PR_TITLE ?? "";
31+
const conventionalTitle = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+$/;
32+
33+
if (conventionalTitle.test(title)) {
34+
console.log(`PR title is a Conventional Commit: ${title}`);
35+
process.exit(0);
36+
}
37+
38+
console.error("PR title must be a Conventional Commit title.");
39+
console.error(`Got: ${title}`);
40+
console.error("Expected examples: feat: add health check, fix(diff): close stale tabs, chore(release): 0.4.0");
41+
process.exit(1);
42+
NODE

.github/workflows/release-please.yaml

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
outputs:
3333
prs_created: ${{ steps.release_please.outputs.prs_created }}
3434
pr_branches: ${{ steps.release_please.outputs.pr_branches }}
35+
pr_metadata: ${{ steps.release_please.outputs.pr_metadata }}
3536
releases_created: ${{ steps.release_please.outputs.releases_created }}
3637
release_tags: ${{ steps.release_please.outputs.release_tags }}
3738
steps:
@@ -76,7 +77,6 @@ jobs:
7677
if: ${{ always() && !cancelled() && needs.release-please.outputs.releases_created == 'true' }}
7778
permissions:
7879
actions: write
79-
contents: read
8080
steps:
8181
- name: Dispatch Communique release notes for created releases
8282
env:
@@ -86,7 +86,7 @@ jobs:
8686
set -euo pipefail
8787
read -ra tags <<< "$RELEASE_TAGS"
8888
for tag in "${tags[@]}"; do
89-
gh workflow run release-notes.yaml --ref "$tag" --field "tag=$tag"
89+
gh workflow run release-notes.yaml --repo "$GITHUB_REPOSITORY" --ref "$tag" --field "tag=$tag"
9090
done
9191
9292
# Branch pushes made with the workflow token do not trigger `pull_request`
@@ -97,15 +97,50 @@ jobs:
9797
if: ${{ always() && !cancelled() && needs.release-please.outputs.prs_created == 'true' }}
9898
permissions:
9999
actions: write
100-
contents: read
101100
steps:
102101
- name: Dispatch checks onto the release PR branch
103102
env:
104103
GH_TOKEN: ${{ github.token }}
105-
PR_BRANCHES: ${{ needs.release-please.outputs.pr_branches }}
104+
PR_METADATA: ${{ needs.release-please.outputs.pr_metadata }}
106105
run: |
107106
set -euo pipefail
108-
read -ra branches <<< "$PR_BRANCHES"
109-
for branch in "${branches[@]}"; do
110-
gh workflow run test.yml --ref "$branch"
111-
done
107+
node <<'NODE'
108+
const { spawnSync } = require("node:child_process");
109+
110+
const repository = process.env.GITHUB_REPOSITORY;
111+
const prMetadata = JSON.parse(process.env.PR_METADATA || "[]");
112+
if (typeof repository !== "string" || repository === "") {
113+
throw new Error("GITHUB_REPOSITORY is required");
114+
}
115+
if (!Array.isArray(prMetadata) || prMetadata.length === 0) {
116+
throw new Error("release PR metadata is required");
117+
}
118+
119+
function runGh(args) {
120+
const result = spawnSync("gh", args, { stdio: "inherit" });
121+
if (result.status !== 0) {
122+
throw new Error(`gh ${args.join(" ")} failed with status ${result.status}`);
123+
}
124+
}
125+
126+
for (const pr of prMetadata) {
127+
if (typeof pr.branch !== "string" || pr.branch === "") {
128+
throw new Error("release PR branch is required");
129+
}
130+
if (typeof pr.title !== "string" || pr.title === "") {
131+
throw new Error(`release PR title is required for branch ${pr.branch}`);
132+
}
133+
runGh([
134+
"workflow",
135+
"run",
136+
"pr-title.yaml",
137+
"--repo",
138+
repository,
139+
"--ref",
140+
pr.branch,
141+
"--raw-field",
142+
`title=${pr.title}`,
143+
]);
144+
runGh(["workflow", "run", "test.yml", "--repo", repository, "--ref", pr.branch]);
145+
}
146+
NODE

.github/workflows/test.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ jobs:
4040
- name: Set up mise
4141
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
4242

43+
- name: Install release tooling dependencies
44+
env:
45+
NPM_CONFIG_IGNORE_SCRIPTS: "true"
46+
run: npm ci --prefix scripts
47+
48+
- name: Run release tooling tests
49+
run: npm --prefix scripts run test:release-please-runner
50+
4351
# Cache the rock tree (rebuilt from luarocks.org otherwise); a miss just rebuilds.
4452
- name: Cache Lua test rocks
4553
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5

scripts/package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "claudecode.nvim-release-tools",
33
"private": true,
44
"devDependencies": {
5+
"prettier": "3.8.4",
56
"release-please": "17.9.0"
67
},
78
"scripts": {

scripts/release-please-runner.js

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
const assert = require("node:assert/strict");
14-
const { execFile } = require("node:child_process");
14+
const { execFile, execFileSync } = require("node:child_process");
1515
const {
1616
appendFileSync,
1717
existsSync,
@@ -36,6 +36,13 @@ const {
3636
const { Simple } = require("release-please/build/src/strategies/simple.js");
3737
const { Changelog } = require("release-please/build/src/updaters/changelog.js");
3838

39+
const LOCAL_PRETTIER_BIN = join(
40+
__dirname,
41+
"node_modules",
42+
".bin",
43+
process.platform === "win32" ? "prettier.cmd" : "prettier",
44+
);
45+
3946
const execFileAsync = promisify(execFile);
4047

4148
const UNRELEASED_HEADING_PATTERN = /^#{2,3} \[?Unreleased\]?[ \t]*$/;
@@ -243,6 +250,29 @@ function findLineOutsideFences(lines, start, predicate) {
243250
return -1;
244251
}
245252

253+
function resolvePrettierBin(env = process.env, fileExists = existsSync) {
254+
if (env.PRETTIER_BIN !== undefined && env.PRETTIER_BIN !== "") {
255+
return env.PRETTIER_BIN;
256+
}
257+
if (fileExists(LOCAL_PRETTIER_BIN)) {
258+
return LOCAL_PRETTIER_BIN;
259+
}
260+
return "prettier";
261+
}
262+
263+
function formatChangelogMarkdown(content) {
264+
assert.equal(typeof content, "string", "content must be a string");
265+
return execFileSync(
266+
resolvePrettierBin(),
267+
["--stdin-filepath", "CHANGELOG.md"],
268+
{
269+
encoding: "utf8",
270+
input: content,
271+
maxBuffer: 16 * 1024 * 1024,
272+
},
273+
);
274+
}
275+
246276
/**
247277
* Replaces release-please's stock CHANGELOG updater. It keeps `## [Unreleased]`
248278
* at the top, clears its draft body after Communique reconciles it into the
@@ -280,7 +310,7 @@ class UnreleasedAwareChangelog {
280310
(line) => NEXT_SECTION_PATTERN.test(line),
281311
);
282312
const rest = nextIndex === -1 ? "" : lines.slice(nextIndex).join("\n");
283-
return joinSections(head, entry, rest);
313+
return formatChangelogMarkdown(joinSections(head, entry, rest));
284314
}
285315

286316
// Self-heal a missing Unreleased anchor so Communique has draft space on
@@ -290,9 +320,13 @@ class UnreleasedAwareChangelog {
290320
);
291321
if (titleIndex !== -1) {
292322
const head = `${lines.slice(0, titleIndex + 1).join("\n")}\n\n## [Unreleased]`;
293-
return joinSections(head, entry, lines.slice(titleIndex + 1).join("\n"));
323+
return formatChangelogMarkdown(
324+
joinSections(head, entry, lines.slice(titleIndex + 1).join("\n")),
325+
);
294326
}
295-
return joinSections("# Changelog\n\n## [Unreleased]", entry, existing);
327+
return formatChangelogMarkdown(
328+
joinSections("# Changelog\n\n## [Unreleased]", entry, existing),
329+
);
296330
}
297331
}
298332

@@ -337,12 +371,16 @@ function formatReleaseOutputs(releases) {
337371

338372
function formatPullRequestOutputs(pullRequests) {
339373
assert.ok(Array.isArray(pullRequests), "pullRequests must be an array");
340-
const branches = pullRequests
374+
const prs = pullRequests
341375
.filter((pullRequest) => pullRequest !== undefined)
342-
.map((pullRequest) => pullRequest.headBranchName);
376+
.map((pullRequest) => ({
377+
branch: pullRequest.headBranchName,
378+
title: pullRequest.title.toString(),
379+
}));
343380
return {
344-
prs_created: branches.length > 0 ? "true" : "false",
345-
pr_branches: branches.join(" "),
381+
prs_created: prs.length > 0 ? "true" : "false",
382+
pr_branches: prs.map((pr) => pr.branch).join(" "),
383+
pr_metadata: JSON.stringify(prs),
346384
};
347385
}
348386

@@ -466,10 +504,12 @@ module.exports = {
466504
buildCommuniqueArgs,
467505
createCommuniqueChangelogNotes,
468506
findLineOutsideFences,
507+
formatChangelogMarkdown,
469508
formatChangelogSection,
470509
formatPullRequestOutputs,
471510
formatReleaseOutputs,
472511
normalizeCommuniqueBody,
512+
resolvePrettierBin,
473513
releasePleaseNotesAreEmpty,
474514
todayIsoDate,
475515
};

scripts/test-release-please-runner.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ function buildOptions() {
2828
};
2929
}
3030

31+
function testPrettierResolution() {
32+
assert.equal(
33+
runner.resolvePrettierBin({ PRETTIER_BIN: "/tmp/prettier" }),
34+
"/tmp/prettier",
35+
);
36+
assert.equal(
37+
runner.resolvePrettierBin({}, () => false),
38+
"prettier",
39+
);
40+
}
41+
3142
async function testCommuniqueArgs() {
3243
assert.deepEqual(
3344
runner.buildCommuniqueArgs({
@@ -66,14 +77,37 @@ async function testHeadingNormalization() {
6677

6778
async function testUnreleasedUpdater() {
6879
const updated = new runner.UnreleasedAwareChangelog({
69-
changelogEntry: "## [0.4.0] - 2026-06-15\n\n- Added x",
80+
changelogEntry: "## [0.4.0] - 2026-06-15\n\n* Added x",
7081
}).updateContent(
7182
"# Changelog\n\n## [Unreleased]\n\n### Features\n\n- Draft\n\n## [0.3.0] - 2025-09-15\n\n- Previous\n",
7283
);
7384
assert.equal(
7485
updated,
7586
"# 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",
7687
);
88+
assert.equal(updated, runner.formatChangelogMarkdown(updated));
89+
}
90+
91+
function testPullRequestOutputs() {
92+
assert.deepEqual(runner.formatPullRequestOutputs([]), {
93+
prs_created: "false",
94+
pr_branches: "",
95+
pr_metadata: "[]",
96+
});
97+
assert.deepEqual(
98+
runner.formatPullRequestOutputs([
99+
{
100+
headBranchName: "release-please--branches--main",
101+
title: { toString: () => "chore(release): 0.4.0" },
102+
},
103+
]),
104+
{
105+
prs_created: "true",
106+
pr_branches: "release-please--branches--main",
107+
pr_metadata:
108+
'[{"branch":"release-please--branches--main","title":"chore(release): 0.4.0"}]',
109+
},
110+
);
77111
}
78112

79113
async function testInternalCommitSkipsCommunique() {
@@ -111,9 +145,11 @@ async function testReleasableCommitRunsCommunique() {
111145
}
112146

113147
async function main() {
148+
testPrettierResolution();
114149
await testCommuniqueArgs();
115150
await testHeadingNormalization();
116151
await testUnreleasedUpdater();
152+
testPullRequestOutputs();
117153
await testInternalCommitSkipsCommunique();
118154
await testReleasableCommitRunsCommunique();
119155
}

0 commit comments

Comments
 (0)