Skip to content

Commit 4ecd643

Browse files
committed
Orchestrate release PR with Changesets CLI
1 parent 19dc944 commit 4ecd643

2 files changed

Lines changed: 179 additions & 6 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
4+
const [statusPath, outputPath] = process.argv.slice(2);
5+
6+
if (!statusPath || !outputPath) {
7+
console.error(
8+
"Usage: node .github/scripts/build-release-pr-body.mjs <status.json> <body.md>",
9+
);
10+
process.exit(1);
11+
}
12+
13+
const rootDir = process.cwd();
14+
const status = JSON.parse(await fs.readFile(statusPath, "utf8"));
15+
const packagesByName = await getPackagesByName(rootDir);
16+
const changesetsById = new Map(
17+
status.changesets.map((changeset) => [changeset.id, changeset]),
18+
);
19+
20+
const releaseBlocks = await Promise.all(
21+
status.releases.map(async (release) => {
22+
const pkg = packagesByName.get(release.name);
23+
const header = `## ${release.name}@${release.newVersion}`;
24+
25+
if (pkg) {
26+
const changelogPath = path.join(pkg.dir, "CHANGELOG.md");
27+
const changelog = await readOptionalFile(changelogPath);
28+
if (changelog) {
29+
const entry = getChangelogEntry(changelog, release.newVersion);
30+
if (entry) return `${header}\n\n${entry}`;
31+
}
32+
}
33+
34+
return `${header}\n\n${fallbackReleaseNotes(release, changesetsById)}`;
35+
}),
36+
);
37+
38+
const body = [
39+
"This PR was opened by the Changesets CLI release workflow. When you're ready to do a release, merge this and publish the Ruby gem manually. If you're not ready to do a release yet, that's fine; whenever you add more changesets to main, this PR will be updated.",
40+
"",
41+
"# Releases",
42+
...releaseBlocks,
43+
]
44+
.join("\n")
45+
.trimEnd();
46+
47+
await fs.writeFile(outputPath, `${body}\n`);
48+
49+
async function getPackagesByName(dir) {
50+
const packages = new Map();
51+
for (const packageJsonPath of await findPackageJsonFiles(dir)) {
52+
const pkg = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
53+
if (pkg.name) {
54+
packages.set(pkg.name, {
55+
dir: path.dirname(packageJsonPath),
56+
packageJson: pkg,
57+
});
58+
}
59+
}
60+
return packages;
61+
}
62+
63+
async function findPackageJsonFiles(dir) {
64+
const ignoredDirs = new Set([
65+
".changesets-checkout",
66+
".git",
67+
"node_modules",
68+
"vendor",
69+
]);
70+
const entries = await fs.readdir(dir, { withFileTypes: true });
71+
const files = [];
72+
73+
for (const entry of entries) {
74+
const entryPath = path.join(dir, entry.name);
75+
if (entry.isDirectory() && !ignoredDirs.has(entry.name)) {
76+
files.push(...(await findPackageJsonFiles(entryPath)));
77+
} else if (entry.isFile() && entry.name === "package.json") {
78+
files.push(entryPath);
79+
}
80+
}
81+
82+
return files;
83+
}
84+
85+
async function readOptionalFile(filePath) {
86+
try {
87+
return await fs.readFile(filePath, "utf8");
88+
} catch (error) {
89+
if (error?.code === "ENOENT") return undefined;
90+
throw error;
91+
}
92+
}
93+
94+
function getChangelogEntry(changelog, version) {
95+
const headingRegex = /^(#{1,6})\s(.*)$/gm;
96+
let match;
97+
let start;
98+
let depth;
99+
let end;
100+
101+
while ((match = headingRegex.exec(changelog))) {
102+
const headingDepth = match[1].length;
103+
const headingText = match[2].trim();
104+
105+
if (headingText === version) {
106+
start = headingRegex.lastIndex;
107+
depth = headingDepth;
108+
continue;
109+
}
110+
111+
if (start !== undefined && headingDepth === depth) {
112+
end = match.index;
113+
break;
114+
}
115+
}
116+
117+
if (start === undefined) return undefined;
118+
return changelog.slice(start, end).trim();
119+
}
120+
121+
function fallbackReleaseNotes(release, changesetsById) {
122+
const changesets = release.changesets
123+
.map((id) => changesetsById.get(id))
124+
.filter(Boolean);
125+
const sections = [
126+
["major", "Major Changes"],
127+
["minor", "Minor Changes"],
128+
["patch", "Patch Changes"],
129+
];
130+
131+
return sections
132+
.map(([type, title]) => {
133+
const summaries = changesets
134+
.filter((changeset) =>
135+
changeset.releases.some(
136+
(changesetRelease) =>
137+
changesetRelease.name === release.name &&
138+
changesetRelease.type === type,
139+
),
140+
)
141+
.map((changeset) => `- ${changeset.summary}`);
142+
143+
return summaries.length > 0
144+
? [`### ${title}`, "", ...summaries].join("\n")
145+
: "";
146+
})
147+
.filter(Boolean)
148+
.join("\n\n");
149+
}

‎.github/workflows/release-pr.yml‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,34 @@ jobs:
4040
pnpm build
4141
4242
- name: Create version pull request
43-
uses: changesets/action@v1
44-
with:
45-
version: node .changesets-checkout/packages/cli/bin.js version
46-
title: Version Ruby gem
47-
commit: Version Ruby gem
4843
env:
49-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45+
RELEASE_BRANCH: changeset-release/main
46+
run: |
47+
git config user.name "github-actions[bot]"
48+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
49+
50+
git checkout -B "$RELEASE_BRANCH" origin/main
51+
52+
node .changesets-checkout/packages/cli/bin.js status --output "$RUNNER_TEMP/changesets-status.json"
53+
node .changesets-checkout/packages/cli/bin.js version
54+
node .github/scripts/build-release-pr-body.mjs "$RUNNER_TEMP/changesets-status.json" "$RUNNER_TEMP/release-pr-body.md"
55+
56+
if [ -z "$(git status --porcelain)" ]; then
57+
echo "No release changes to commit."
58+
exit 0
59+
fi
60+
61+
git add .
62+
git commit -m "Version Ruby gem"
63+
git push origin HEAD:"$RELEASE_BRANCH" --force
64+
65+
if pr_number="$(gh pr view "$RELEASE_BRANCH" --json number --jq .number 2>/dev/null)"; then
66+
gh pr edit "$pr_number" --title "Version Ruby gem" --body-file "$RUNNER_TEMP/release-pr-body.md"
67+
else
68+
gh pr create \
69+
--base main \
70+
--head "$RELEASE_BRANCH" \
71+
--title "Version Ruby gem" \
72+
--body-file "$RUNNER_TEMP/release-pr-body.md"
73+
fi

0 commit comments

Comments
 (0)