diff --git a/docs/publishing.md b/docs/publishing.md index fbbef93493..a0d67b7d24 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -67,12 +67,3 @@ process is used to release an emergency fix as version `6.5.1` in the patch rele - [ ] The fix, which you can likely cherry-pick from your patch branch: `git cherry-pick origin/patch/v1.18.0^` - [ ] An updated `CHANGELOG.md` of all patched packages from the tip of the patch branch, `git checkout origin/patch/v1.18.0 -- {packages,plugins}/*/CHANGELOG.md`. - [ ] A changeset with the message "Applied the fix from version `6.5.1` of this package, which is part of the `v1.18.1` release of Backstage." - - [ ] An entry in `.changeset/patched.json` that sets the current release version to `6.5.1`: - - ```json - { - "currentReleaseVersion": { - "@backstage/plugin-foo": "6.5.1" - } - } - ``` diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js index be82e19344..5cc888fd66 100755 --- a/scripts/prepare-release.js +++ b/scripts/prepare-release.js @@ -28,6 +28,10 @@ const execFile = promisify(execFileCb); // All of these are considered to be main-line release branches const MAIN_BRANCHES = ['master', 'origin/master', 'changeset-release/master']; +// This prefix is used for patch branches, followed by the release version WITH a 'v' prefix +// For example, `patch/v1.2.0` +const PATCH_BRANCH_PREFIX = 'patch/'; + const DEPENDENCY_TYPES = [ 'dependencies', 'devDependencies', @@ -35,25 +39,84 @@ const DEPENDENCY_TYPES = [ 'peerDependencies', ]; +/** + * Returns the most recent release version on the main branch that is not a pre-release. + */ +async function getPreviousReleaseVersion(repo) { + // TODO(Rugvip): Figure out which field to sort by to avoid manual sort after + const { stdout: tagsStr } = await execFile( + 'git', + ['tag', '--list', 'v*', '--merged=HEAD'], + { shell: true, cwd: repo.root.dir }, + ); + const tags = tagsStr.trim().split(/\r\n|\n/); + const [latestTag] = semver.rsort(tags).filter(t => !semver.prerelease(t)); + return latestTag; +} + +/** + * Finds the tip of the patch branch of a given release version. + * Returns undefined if no patch branch exists. + */ +async function findTipOfPatchBranch(repo, release) { + try { + await execFile('git', ['fetch', 'origin', PATCH_BRANCH_PREFIX + release], { + shell: true, + cwd: repo.root.dir, + }); + } catch (error) { + if (error.stderr?.match(/fatal: couldn't find remote ref/i)) { + return undefined; + } + throw error; + } + const { stdout: refStr } = await execFile('git', ['rev-parse', 'FETCH_HEAD']); + return refStr.trim(); +} + +/** + * Returns a map of packages to their versions for any package version + * in that does not match the current version in the working directory. + */ +async function detectPatchVersionsForRef(repo, ref) { + const patchVersions = new Map(); + + for (const pkg of repo.packages) { + const pkgJsonPath = path.join( + path.relative(repo.root.dir, pkg.dir), + 'package.json', + ); + const { stdout: pkgJsonStr } = await execFile('git', [ + 'show', + `${ref}:${pkgJsonPath}`, + ]); + if (pkgJsonStr) { + const releasePkgJson = JSON.parse(pkgJsonStr); + const pkgJson = pkg.packageJson; + if (releasePkgJson.name !== pkgJson.name) { + throw new Error( + `Mismatched package name at ${pkg.dir}, ${releasePkgJson.name} !== ${pkgJson.name}`, + ); + } + if (releasePkgJson.version !== pkgJson.version) { + patchVersions.set(pkgJson.name, releasePkgJson.version); + } + } + } + + return patchVersions; +} + /** * Bumps up the versions of packages to account for * the base versions that are set in .changeset/patched.json. * This may be needed when we have made emergency releases. */ -async function updatePatchVersions() { - const patchedJsonPath = path.resolve('.changeset', 'patched.json'); - const { currentReleaseVersion } = await fs.readJson(patchedJsonPath); - if (Object.keys(currentReleaseVersion).length === 0) { - console.log('No currentReleaseVersion overrides found, skipping.'); - return; - } - - const { packages } = await getPackages(path.resolve('.')); - +async function applyPatchVersions(repo, patchVersions) { const pendingVersionBumps = new Map(); - for (const [name, version] of Object.entries(currentReleaseVersion)) { - const pkg = packages.find(p => p.packageJson.name === name); + for (const [name, version] of patchVersions) { + const pkg = repo.packages.find(p => p.packageJson.name === name); if (!pkg) { throw new Error(`Package ${name} not found`); } @@ -81,7 +144,7 @@ async function updatePatchVersions() { }); } - for (const { dir, packageJson } of packages) { + for (const { dir, packageJson } of [repo.root, ...repo.packages]) { let hasChanges = false; if (pendingVersionBumps.has(packageJson.name)) { @@ -117,20 +180,44 @@ async function updatePatchVersions() { }); } } +} - await fs.writeJSON( - patchedJsonPath, - { currentReleaseVersion: {} }, - { spaces: 2, encoding: 'utf8' }, - ); +/** + * Detects any patched packages version since the most recent release on + * the main branch, and then bumps all packages in the repo accordingly. + */ +async function updatePackageVersions(repo) { + const previousRelease = await getPreviousReleaseVersion(repo); + console.log(`Found release version: ${previousRelease}`); + + const patchRef = await findTipOfPatchBranch(repo, previousRelease); + if (patchRef) { + console.log(`Tip of the patch branch: ${patchRef}`); + + const patchVersions = await detectPatchVersionsForRef(repo, patchRef); + if (patchVersions.size > 0) { + console.log( + `Found ${patchVersions.size} packages that were patched since the last release`, + ); + for (const [name, version] of patchVersions) { + console.log(` ${name}: ${version}`); + } + + await applyPatchVersions(repo, patchVersions); + } else { + console.log('No packages were patched since the last release'); + } + } else { + console.log('No patch branch found'); + } } /** * Returns the mode and tag that is currently set * in the .changeset/pre.json file */ -async function getPreInfo(rootPath) { - const pre = path.join(rootPath, '.changeset', 'pre.json'); +async function getPreInfo(repo) { + const pre = path.join(repo.root.dir, '.changeset', 'pre.json'); if (!(await fs.pathExists(pre))) { return { mode: undefined, tag: undefined }; } @@ -139,26 +226,30 @@ async function getPreInfo(rootPath) { return { mode, tag }; } +/** + * Returns the name of the current git branch + */ +async function getCurrentBranch(repo) { + const { stdout } = await execFile( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { cwd: repo.root.dir, shell: true }, + ); + return stdout.trim(); +} + /** * Bumps the release version in the root package.json. * * This takes into account whether we're in pre-release mode or on a patch branch. */ -async function updateBackstageReleaseVersion() { - const rootPath = path.resolve(__dirname, '..'); - const branchName = await execFile( - 'git', - ['rev-parse', '--abbrev-ref', 'HEAD'], - { shell: true }, - ).then(({ stdout }) => stdout.trim()); - const { mode: preMode, tag: preTag } = await getPreInfo(rootPath); +async function updateBackstageReleaseVersion(repo, type) { + const { mode: preMode, tag: preTag } = await getPreInfo(repo); - const packagePath = path.join(rootPath, 'package.json'); - const package = await fs.readJson(packagePath); - const { version: currentVersion } = package; + const { version: currentVersion } = repo.root.packageJson; let nextVersion; - if (MAIN_BRANCHES.includes(branchName)) { + if (type === 'minor') { if (preMode === 'pre') { if (semver.prerelease(currentVersion)) { nextVersion = semver.inc(currentVersion, 'pre', preTag); @@ -170,7 +261,7 @@ async function updateBackstageReleaseVersion() { } else { nextVersion = semver.inc(currentVersion, 'minor'); } - } else { + } else if (type === 'patch') { if (preMode) { throw new Error(`Unexpected pre mode ${preMode} on branch ${branchName}`); } @@ -178,9 +269,9 @@ async function updateBackstageReleaseVersion() { } await fs.writeJson( - packagePath, + path.join(repo.root.dir, 'package.json'), { - ...package, + ...repo.root.packageJson, version: nextVersion, }, { spaces: 2, encoding: 'utf8' }, @@ -188,8 +279,17 @@ async function updateBackstageReleaseVersion() { } async function main() { - await updatePatchVersions(); - await updateBackstageReleaseVersion(); + const repo = await getPackages(__dirname); + const branchName = await getCurrentBranch(repo); + const isMainBranch = MAIN_BRANCHES.includes(branchName); + + console.log(`Current branch: ${branchName}`); + if (isMainBranch) { + console.log('Main release, updating package versions'); + await updatePackageVersions(repo); + } + + await updateBackstageReleaseVersion(repo, isMainBranch ? 'minor' : 'patch'); } main().catch(error => {