Skip to content

Commit dd61d2a

Browse files
ci: Release and Changelog automation (#39)
* chore: Remove old CI pipelines (not needed anymore) * docs: Update PR template * ci: Validate PR title workflow * ci: Workflows and script for release automation
1 parent 4d67e40 commit dd61d2a

29 files changed

Lines changed: 557 additions & 1052 deletions
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env node
2+
/*
3+
* Computes the next version for a PR and updates package.json, plugin.xml,
4+
* and CHANGELOG.md inside PR_DIR accordingly.
5+
*
6+
* This script is always executed from the default-branch clone (never from
7+
* PR-controlled code). It reads PR metadata via environment variables (no
8+
* shell interpolation) and writes outputs to the file pointed to by
9+
* $GITHUB_OUTPUT.
10+
*
11+
* Required env vars:
12+
* PR_TITLE PR title
13+
* PR_BODY PR body (may be empty)
14+
* PR_NUMBER PR number
15+
* COMMIT_FOOTERS Concatenated commit messages from the PR
16+
* MAIN_DIR Path to the default-branch clone (read-only baseline)
17+
* PR_DIR Path to the PR head clone (files are rewritten here)
18+
*
19+
* Optional:
20+
* GITHUB_OUTPUT Path to GH Actions step outputs file; falls back to stdout
21+
*
22+
* Outputs (written to $GITHUB_OUTPUT or stdout):
23+
* bump=major|minor|patch|none
24+
* version=X.Y.Z (omitted when bump=none)
25+
* changed=true|false
26+
*/
27+
28+
const fs = require('node:fs');
29+
const path = require('node:path');
30+
31+
const PR_TITLE = process.env.PR_TITLE || '';
32+
const PR_BODY = process.env.PR_BODY || '';
33+
const PR_NUMBER = process.env.PR_NUMBER || '';
34+
const COMMIT_FOOTERS = process.env.COMMIT_FOOTERS || '';
35+
const MAIN_DIR = process.env.MAIN_DIR;
36+
const PR_DIR = process.env.PR_DIR;
37+
const OUTPUT_PATH = process.env.GITHUB_OUTPUT;
38+
39+
if (!MAIN_DIR || !PR_DIR) {
40+
console.error('MAIN_DIR and PR_DIR env vars are required');
41+
process.exit(1);
42+
}
43+
44+
function writeOutput(key, value) {
45+
const line = `${key}=${value}\n`;
46+
if (OUTPUT_PATH) {
47+
fs.appendFileSync(OUTPUT_PATH, line);
48+
} else {
49+
process.stdout.write(`OUTPUT ${line}`);
50+
}
51+
}
52+
53+
const TITLE_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
54+
const BREAKING_RE = /^BREAKING CHANGE:/m;
55+
56+
function parseTitle(title) {
57+
const m = title.match(TITLE_RE);
58+
if (!m) return null;
59+
return {
60+
type: m[1],
61+
scope: m[2] || null,
62+
breaking: Boolean(m[3]),
63+
description: m[4],
64+
};
65+
}
66+
67+
function hasBreakingChange(parsed) {
68+
if (parsed.breaking) return true;
69+
if (BREAKING_RE.test(PR_BODY)) return true;
70+
if (BREAKING_RE.test(COMMIT_FOOTERS)) return true;
71+
return false;
72+
}
73+
74+
function computeBump(parsed) {
75+
if (hasBreakingChange(parsed)) return 'major';
76+
if (parsed.type === 'feat') return 'minor';
77+
if (parsed.type === 'fix' || parsed.type === 'chore' || parsed.type === 'refactor') return 'patch';
78+
return 'none';
79+
}
80+
81+
function bumpVersion(version, bump) {
82+
const [major, minor, patch] = version.split('.').map(Number);
83+
if (bump === 'major') return `${major + 1}.0.0`;
84+
if (bump === 'minor') return `${major}.${minor + 1}.0`;
85+
if (bump === 'patch') return `${major}.${minor}.${patch + 1}`;
86+
return version;
87+
}
88+
89+
function subsectionFor(bump, type) {
90+
if (bump === 'major') return '### BREAKING CHANGES';
91+
if (type === 'feat') return '### Features';
92+
if (type === 'fix') return '### Fixes';
93+
return '### Chores';
94+
}
95+
96+
// Detects whether the CHANGELOG uses "## [X.Y.Z]" (bracketed) or "## X.Y.Z" (plain).
97+
function detectChangelogFormat(content) {
98+
const m = content.match(/^##\s+(\[?\d+\.\d+\.\d+\]?)/m);
99+
if (m && m[1].startsWith('[')) return 'bracketed';
100+
return 'plain';
101+
}
102+
103+
// Handles: "## X.Y.Z", "## [X.Y.Z]", and "## [X.Y.Z] - YYYY-MM-DD"
104+
function latestChangelogVersion(content) {
105+
const m = content.match(/^##\s+\[?(\d+\.\d+\.\d+)\]?(?:\s+-\s+\S+)?\s*$/m);
106+
return m ? m[1] : null;
107+
}
108+
109+
function readFileOrEmpty(p) {
110+
try {
111+
return fs.readFileSync(p, 'utf8');
112+
} catch {
113+
return '';
114+
}
115+
}
116+
117+
function readMainPackageVersion() {
118+
const p = path.join(MAIN_DIR, 'package.json');
119+
try {
120+
const json = JSON.parse(fs.readFileSync(p, 'utf8'));
121+
return json.version || null;
122+
} catch {
123+
return null;
124+
}
125+
}
126+
127+
function buildChangelogEntry(version, bump, parsed, format) {
128+
const sub = subsectionFor(bump, parsed.type);
129+
const prRef = PR_NUMBER ? ` (#${PR_NUMBER})` : '';
130+
const versionTag = format === 'bracketed' ? `[${version}]` : version;
131+
return `## ${versionTag}\n\n${sub}\n\n- ${PR_TITLE}${prRef}\n\n`;
132+
}
133+
134+
function rebuildChangelog(mainChangelog, newEntry) {
135+
if (!newEntry) return mainChangelog;
136+
const firstSection = mainChangelog.match(/^##\s+/m);
137+
if (firstSection) {
138+
const idx = mainChangelog.indexOf(firstSection[0]);
139+
return mainChangelog.slice(0, idx) + newEntry + mainChangelog.slice(idx);
140+
}
141+
const header = mainChangelog.trim().length > 0
142+
? mainChangelog.trimEnd() + '\n\n'
143+
: '# Changelog\n\n';
144+
return header + newEntry;
145+
}
146+
147+
function rewritePackageVersion(raw, version) {
148+
const re = /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/;
149+
if (!re.test(raw)) {
150+
throw new Error('Could not locate "version" field in package.json');
151+
}
152+
return raw.replace(re, `$1${version}$3`);
153+
}
154+
155+
function rewritePluginXmlVersion(raw, version) {
156+
// Match the version attribute on the opening <plugin> tag only.
157+
// [^>]*? ensures we don't cross past the closing > of that tag.
158+
const re = /(<plugin\b[^>]*?\bversion=")(\d+\.\d+\.\d+)(")/;
159+
if (!re.test(raw)) {
160+
throw new Error('Could not locate version attribute on <plugin> element in plugin.xml');
161+
}
162+
return raw.replace(re, `$1${version}$3`);
163+
}
164+
165+
async function main() {
166+
const parsed = parseTitle(PR_TITLE);
167+
if (!parsed) {
168+
console.error(`PR title does not match conventional commits format: "${PR_TITLE}"`);
169+
process.exit(1);
170+
}
171+
172+
const bump = computeBump(parsed);
173+
const mainChangelogPath = path.join(MAIN_DIR, 'CHANGELOG.md');
174+
const mainChangelog = readFileOrEmpty(mainChangelogPath);
175+
const format = detectChangelogFormat(mainChangelog);
176+
const lastLogged = latestChangelogVersion(mainChangelog);
177+
const mainPkgVersion = readMainPackageVersion();
178+
const baseline = lastLogged || mainPkgVersion;
179+
180+
let targetVersion;
181+
let newChangelog;
182+
183+
if (bump === 'none') {
184+
targetVersion = baseline;
185+
newChangelog = mainChangelog;
186+
console.log(`PR type "${parsed.type}" does not trigger a release.`);
187+
} else if (!lastLogged) {
188+
targetVersion = '1.0.0';
189+
newChangelog = rebuildChangelog(
190+
mainChangelog,
191+
buildChangelogEntry(targetVersion, bump, parsed, format),
192+
);
193+
console.log('First release detected — defaulting to 1.0.0.');
194+
} else {
195+
targetVersion = bumpVersion(baseline, bump);
196+
newChangelog = rebuildChangelog(
197+
mainChangelog,
198+
buildChangelogEntry(targetVersion, bump, parsed, format),
199+
);
200+
}
201+
202+
console.log(`Baseline version: ${baseline || '<none>'}`);
203+
console.log(`Bump: ${bump}`);
204+
console.log(`Target version: ${targetVersion || '<unchanged>'}`);
205+
console.log(`CHANGELOG format: ${format}`);
206+
207+
if (!targetVersion) {
208+
console.log('No baseline version available and no release triggered — nothing to do.');
209+
writeOutput('bump', 'none');
210+
writeOutput('changed', 'false');
211+
return;
212+
}
213+
214+
const pkgPath = path.join(PR_DIR, 'package.json');
215+
const xmlPath = path.join(PR_DIR, 'plugin.xml');
216+
const changelogPath = path.join(PR_DIR, 'CHANGELOG.md');
217+
218+
const pkgRaw = fs.readFileSync(pkgPath, 'utf8');
219+
const xmlRaw = fs.readFileSync(xmlPath, 'utf8');
220+
const currentChangelog = readFileOrEmpty(changelogPath);
221+
222+
const newPkgRaw = rewritePackageVersion(pkgRaw, targetVersion);
223+
const newXmlRaw = rewritePluginXmlVersion(xmlRaw, targetVersion);
224+
225+
const changed =
226+
newPkgRaw !== pkgRaw ||
227+
newXmlRaw !== xmlRaw ||
228+
newChangelog !== currentChangelog;
229+
230+
if (!changed) {
231+
console.log('No changes needed — files already at target state.');
232+
writeOutput('bump', bump);
233+
if (bump !== 'none') writeOutput('version', targetVersion);
234+
writeOutput('changed', 'false');
235+
return;
236+
}
237+
238+
fs.writeFileSync(pkgPath, newPkgRaw);
239+
fs.writeFileSync(xmlPath, newXmlRaw);
240+
fs.writeFileSync(changelogPath, newChangelog);
241+
242+
console.log(`Updated package.json, plugin.xml, CHANGELOG.md to ${targetVersion}`);
243+
writeOutput('bump', bump);
244+
if (bump !== 'none') writeOutput('version', targetVersion);
245+
writeOutput('changed', 'true');
246+
}
247+
248+
main().catch((err) => {
249+
console.error(err);
250+
process.exit(1);
251+
});

0 commit comments

Comments
 (0)