|
| 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