diff --git a/packages/release-tool/package.json b/packages/release-tool/package.json index 0103f14d81..d504c0d6d1 100644 --- a/packages/release-tool/package.json +++ b/packages/release-tool/package.json @@ -16,15 +16,14 @@ "devDependencies": { "@swc/cli": "^0.1.61", "@swc/core": "^1.3.35", - "@types/command-line-args": "^5.2.0", - "@types/fs-extra": "^11.0.1", + "@types/inquirer": "^9.0.3", "@types/node": "^16.18.11", "@types/semver": "^7.3.13", - "command-line-args": "^5.2.1", - "fs-extra": "^11.1.0", - "semver": "^7.3.8" + "rimraf": "^4.1.2" }, "dependencies": { - "rimraf": "^4.1.2" + "chalk": "^5.2.0", + "inquirer": "^9.1.4", + "semver": "^7.3.8" } } diff --git a/packages/release-tool/src/index.ts b/packages/release-tool/src/index.ts index ec75f5e6d2..7a4ce49a27 100755 --- a/packages/release-tool/src/index.ts +++ b/packages/release-tool/src/index.ts @@ -3,122 +3,22 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ +import assert from "assert"; +import chalk from "chalk"; import child_process from "child_process"; -import commandLineArgs from "command-line-args"; -import fse from "fs-extra"; -import { basename } from "path"; -import { createInterface } from "readline"; +import { readFile } from "fs/promises"; +import inquirer from "inquirer"; +import { createInterface, ReadLine } from "readline"; import semver from "semver"; import { promisify } from "util"; -const { - SemVer, - valid: semverValid, - rcompare: semverRcompare, - lte: semverLte, -} = semver; +type SemVer = semver.SemVer; + +const { SemVer } = semver; const exec = promisify(child_process.exec); +const spawn = promisify(child_process.spawn); const execFile = promisify(child_process.execFile); -const options = commandLineArgs([ - { - name: "type", - defaultOption: true, - }, - { - name: "preid", - }, - { - name: "check-commits", - type: Boolean, - }, -]); - -const validReleaseValues = [ - "major", - "minor", - "patch", -]; -const validPrereleaseValues = [ - "premajor", - "preminor", - "prepatch", - "prerelease", -]; -const validPreidValues = [ - "alpha", - "beta", -]; - -const errorMessages = { - noReleaseType: `No release type provided. Valid options are: ${[...validReleaseValues, ...validPrereleaseValues].join(", ")}`, - invalidRelease: (invalid: string) => `Invalid release type was provided (value was "${invalid}"). Valid options are: ${[...validReleaseValues, ...validPrereleaseValues].join(", ")}`, - noPreid: `No preid was provided. Use '--preid' to specify. Valid options are: ${validPreidValues.join(", ")}`, - invalidPreid: (invalid: string) => `Invalid preid was provided (value was "${invalid}"). Valid options are: ${validPreidValues.join(", ")}`, - wrongCwd: "It looks like you are running this script from the 'scripts' directory. This script assumes it is run from the root of the git repo", -}; - -if (!options.type) { - console.error(errorMessages.noReleaseType); - process.exit(1); -} - -if (validReleaseValues.includes(options.type)) { - // do nothing, is valid -} else if (validPrereleaseValues.includes(options.type)) { - if (!options.preid) { - console.error(errorMessages.noPreid); - process.exit(1); - } - - if (!validPreidValues.includes(options.preid)) { - console.error(errorMessages.invalidPreid(options.preid)); - process.exit(1); - } -} else { - console.error(errorMessages.invalidRelease(options.type)); - process.exit(1); -} - -if (basename(process.cwd()) === "scripts") { - console.error(errorMessages.wrongCwd); -} - -const currentVersion = new SemVer((await fse.readJson("./lerna.json")).version); - -console.log(`current version: ${currentVersion.format()}`); - -const newVersion = currentVersion.inc(options.type, options.preid); -const newVersionMilestone = `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`; -const prBranch = `release/v${newVersion.format()}`; - -await exec(`yarn run bump-version --yes ${newVersion.format()} --force-publish`); -await exec(`git checkout -b ${prBranch}`); -await exec("git add lerna.json packages/*/package.json"); -await exec(`git commit -sm "Release ${newVersion.format()}"`); - -console.log(`new version: ${newVersion.format()}`); - -console.log("fetching tags..."); -await exec("git fetch --tags --force"); - -const actualTags = (await exec("git tag --list", { encoding: "utf-8" })).stdout.split(/\r?\n/).map(line => line.trim()); -const [previousReleasedVersion] = actualTags - .map((value) => semverValid(value)) - .filter((v): v is string => typeof v === "string") - .sort((l, r) => semverRcompare(l, r)) - .filter(version => semverLte(version, currentVersion)); - -const getMergedPrsArgs = [ - "gh", - "pr", - "list", - "--limit=500", // Should be big enough, if not we need to release more often ;) - "--state=merged", - "--base=master", - "--json mergeCommit,title,author,labels,number,milestone,mergedAt", -]; - interface GithubPrData { author: { login: string; @@ -147,168 +47,296 @@ interface ExtendedGithubPrData extends Omit { mergedAt: Date; } -console.log("retreiving last 500 PRs to create release PR body..."); -const mergedPrs = JSON.parse((await exec(getMergedPrsArgs.join(" "), { encoding: "utf-8" })).stdout) as GithubPrData[]; -const milestoneRelevantPrs = mergedPrs.filter(pr => pr.milestone?.title === newVersionMilestone); -const relaventPrsQuery = await Promise.all( - milestoneRelevantPrs.map(async pr => ({ - pr, - stdout: (await exec(`git tag v${previousReleasedVersion} --no-contains ${pr.mergeCommit.oid}`)).stdout, - })), -); -const relaventPrs = relaventPrsQuery - .filter(query => query.stdout) - .map(query => query.pr) - .filter(pr => pr.labels.every(label => label.name !== "skip-changelog")) - .map(pr => ({ ...pr, mergedAt: new Date(pr.mergedAt) } as ExtendedGithubPrData)) - .sort((left, right) => { - const leftAge = left.mergedAt.valueOf(); - const rightAge = right.mergedAt.valueOf(); +async function getCurrentBranch(): Promise { + return (await exec("git branch --show-current")).stdout.trim(); +} - if (leftAge === rightAge) { - return 0; - } +async function getAbsolutePathToRepoRoot(): Promise { + return (await exec("git rev-parse --show-toplevel")).stdout.trim(); +} - if (leftAge > rightAge) { - return 1; - } +async function fetchAllGitTags(): Promise { + await execFile("git", ["fetch", "--tags", "--force"]); - return -1; + const { stdout } = await exec("git tag --list", { encoding: "utf-8" }); + + return stdout + .split(/\r?\n/) + .map(line => line.trim()); +} + +async function bumpPackageVersions(): Promise { + await spawn("npm", ["run", "bump-version"], { + stdio: "inherit", + }); +} + +function isDefined(value: T | null | undefined): value is T { + return value != null; +} + +function findClosestVersionTagLessThanVersion(tags: string[], version: SemVer): string { + const lessThanTags = tags + .map((value) => semver.parse(value)) + .filter(isDefined) + .filter(version => !version.prerelease.includes("cron")) + .sort(semver.rcompare) + .filter(version => semver.lte(version, version)); + + assert(lessThanTags.length > 0, `Cannot find version tag less than ${version.format()}`); + + return lessThanTags[0].format(); +} + +async function getCurrentVersionOfSubPackage(packageName: string): Promise { + const packageJson = JSON.parse(await readFile(`./packages/${packageName}/package.json`, "utf-8")); + + return new SemVer(packageJson.version); +} + +async function checkCurrentWorkingDirectory(): Promise { + const repoRoot = await getAbsolutePathToRepoRoot(); + + if (process.cwd() !== repoRoot) { + console.error("It looks like you are running this script from the 'scripts' directory. This script assumes it is run from the root of the git repo"); + process.exit(1); + } +} + +function formatSemverForMilestone(version: SemVer): string { + return `${version.major}.${version.minor}.${version.patch}`; +} + +async function createReleaseBranchAndCommit(prBase: string, version: SemVer, prBody: string): Promise { + const prBranch = `release/v${version.format()}`; + + await spawn("git", ["checkout", "-b", prBranch], { + stdio: "inherit", + }); + await spawn("git", ["add", "lerna.json", "packages/*/package.json"], { + stdio: "inherit", + }); + await spawn("git", ["commit", "-sm", `"Release ${version.format()}"`], { + stdio: "inherit", + }); + await spawn("git", ["push", "--set-upstream", "origin", prBranch], { + stdio: "inherit", }); -const enhancementPrLabelName = "enhancement"; -const bugfixPrLabelName = "bug"; + await spawn("gh", [ + "pr", + "create", + "--base", prBase, + "--title", `Release ${version.format()}`, + "--label", "skip-changelog", + "--label", "release", + "--milestone", formatSemverForMilestone(version), + "--body-file", prBody, + ], { + stdio: "inherit" + }); +} -const isEnhancementPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === enhancementPrLabelName); -const isBugfixPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === bugfixPrLabelName); +function sortExtendedGithubPrData(left: ExtendedGithubPrData, right: ExtendedGithubPrData): number { + const leftAge = left.mergedAt.valueOf(); + const rightAge = right.mergedAt.valueOf(); -const prLines = { - enhancement: [] as string[], - bugfix: [] as string[], - maintenence: [] as string[], -}; + if (leftAge === rightAge) { + return 0; + } -function getPrEntry(pr: ExtendedGithubPrData) { + if (leftAge > rightAge) { + return 1; + } + + return -1; +} + +async function getRelevantPRs(milestone: string, previousReleasedVersion: string): Promise { + console.log("retreiving previous 500 PRs..."); + + const getMergedPrsArgs = [ + "gh", + "pr", + "list", + "--limit=500", // Should be big enough, if not we need to release more often ;) + "--state=merged", + "--base=master", + "--json mergeCommit,title,author,labels,number,milestone,mergedAt", + ]; + + const mergedPrs = JSON.parse((await exec(getMergedPrsArgs.join(" "), { encoding: "utf-8" })).stdout) as GithubPrData[]; + const milestoneRelevantPrs = mergedPrs.filter(pr => pr.milestone?.title === milestone); + const relaventPrsQuery = await Promise.all( + milestoneRelevantPrs.map(async pr => ({ + pr, + stdout: (await exec(`git tag v${previousReleasedVersion} --no-contains ${pr.mergeCommit.oid}`)).stdout, + })), + ); + + return relaventPrsQuery + .filter(query => query.stdout) + .map(query => query.pr) + .filter(pr => pr.labels.every(label => label.name !== "skip-changelog")) + .map(pr => ({ ...pr, mergedAt: new Date(pr.mergedAt) } as ExtendedGithubPrData)) + .sort(sortExtendedGithubPrData); +} + +function formatPrEntry(pr: ExtendedGithubPrData) { return `- ${pr.title} (**[#${pr.number}](https://github.com/lensapp/lens/pull/${pr.number})**) https://github.com/${pr.author.login}`; } -const rl = createInterface(process.stdin); -const prBase = newVersion.patch === 0 - ? "master" - : `release/v${newVersion.major}.${newVersion.minor}`; +const isEnhancementPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === "enhancement"); +const isBugfixPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === "bug"); -function askQuestion(question: string): Promise { - return new Promise(resolve => { - function _askQuestion() { - console.log(question); +const cherrypickCommitWith = (rl: ReadLine) => async (commit: string) => { + try { + const cherryPick = child_process.spawn("git", ["cherry-pick", commit]); - rl.once("line", (answer) => { - const cleaned = answer.trim().toLowerCase(); + cherryPick.stdout.pipe(process.stdout); + cherryPick.stderr.pipe(process.stderr); - if (cleaned === "y") { - resolve(true); - } else if (cleaned === "n") { - resolve(false); - } else { - _askQuestion(); + await new Promise((resolve, reject) => { + const cleaners: (() => void)[] = []; + const cleanup = () => cleaners.forEach(cleaner => cleaner()); + + const onExit = (code: number | null) => { + if (code) { + reject(new Error(`git cherry-pick failed with exit code ${code}`)); + cleanup(); } - }); - } - _askQuestion(); + resolve(); + cleanup(); + }; + + cherryPick.once("exit", onExit); + cleaners.push(() => cherryPick.off("exit", onExit)); + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + cherryPick.once("error", onError); + cleaners.push(() => cherryPick.off("error", onError)); + }); + } catch { + console.error(chalk.bold("Please resolve conflicts in a seperate terminal and then press enter here...")); + await new Promise(resolve => rl.once("line", () => resolve())); + } +}; + +async function pickWhichPRsToUse(prs: ExtendedGithubPrData[]): Promise { + const answers = await inquirer.prompt<{ commits: number[] }>({ + type: "checkbox", + name: `commits`, + message: "Pick which commits to use...", + default: [], + choices: prs.map(pr => ({ + checked: true, + key: pr.number, + name: `#${pr.number}: ${pr.title} (https://github.com/lensapp/lens/pull/${pr.number})`, + value: pr.number, + short: `#${pr.number}`, + })), + loop: false, }); + + return prs.filter(pr => answers.commits.includes(pr.number)); } -async function handleRelaventPr(pr: ExtendedGithubPrData) { - if (options["check-commits"] && !(await askQuestion(`Would you like to use #${pr.number}: ${pr.title}? - Y/N`))) { - return; - } +function formatChangelog(previousReleasedVersion: string, prs: ExtendedGithubPrData[]): string { + const enhancementPrLines: string[] = []; + const bugPrLines: string[] = []; + const maintenencePrLines: string[] = []; - if (prBase !== "master") { - try { - const promise = exec(`git cherry-pick ${pr.mergeCommit.oid}`); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - promise.child.stdout!.pipe(process.stdout); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - promise.child.stderr!.pipe(process.stderr); - - await promise; - } catch { - console.error(`Failed to cherry-pick ${pr.mergeCommit.oid}, please resolve conflicts and then press enter here:`); - await new Promise(resolve => rl.once("line", () => resolve())); + for (const pr of prs) { + if (isEnhancementPr(pr)) { + enhancementPrLines.push(formatPrEntry(pr)); + } else if (isBugfixPr(pr)) { + bugPrLines.push(formatPrEntry(pr)); + } else { + maintenencePrLines.push(formatPrEntry(pr)); } } - if (isEnhancementPr(pr)) { - prLines.enhancement.push(getPrEntry(pr)); - } else if (isBugfixPr(pr)) { - prLines.bugfix.push(getPrEntry(pr)); - } else { - prLines.maintenence.push(getPrEntry(pr)); + if (enhancementPrLines.length > 0) { + enhancementPrLines.unshift("## 🚀 Features", ""); + enhancementPrLines.push(""); } + + if (bugPrLines.length > 0) { + bugPrLines.unshift("## 🐛 Bug Fixes", ""); + bugPrLines.push(""); + } + + if (maintenencePrLines.length > 0) { + maintenencePrLines.unshift("## 🧰 Maintenance", ""); + maintenencePrLines.push(""); + } + + return [ + `## Changes since ${previousReleasedVersion}`, + "", + ...enhancementPrLines, + ...bugPrLines, + ...maintenencePrLines, + ].join("\n"); } -for (const pr of relaventPrs) { - await handleRelaventPr(pr); +async function cherrypickCommits(prs: ExtendedGithubPrData[]): Promise { + const rl = createInterface(process.stdin); + const cherrypickCommit = cherrypickCommitWith(rl); + + for (const pr of prs) { + await cherrypickCommit(pr.mergeCommit.oid); + } + + rl.close(); } -rl.close(); +async function pickRelaventPrs(prs: ExtendedGithubPrData[], isMasterBranch: boolean): Promise { + if (isMasterBranch) { + return prs; + } -const prBodyLines = [ - `## Changes since ${previousReleasedVersion}`, - "", - ...( - prLines.enhancement.length > 0 - ? [ - "## 🚀 Features", - "", - ...prLines.enhancement, - "", - ] - : [] - ), - ...( - prLines.bugfix.length > 0 - ? [ - "## 🐛 Bug Fixes", - "", - ...prLines.bugfix, - "", - ] - : [] - ), - ...( - prLines.maintenence.length > 0 - ? [ - "## 🧰 Maintenance", - "", - ...prLines.maintenence, - "", - ] - : [] - ), -]; -const prBody = prBodyLines.join("\n"); -const createPrArgs = [ - "pr", - "create", - "--base", prBase, - "--title", `Release ${newVersion.format()}`, - "--label", "skip-changelog", - "--label", "release", - "--milestone", `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`, - "--body-file", "-", -]; + let selectedPrs: ExtendedGithubPrData[]; -await exec(`git push --set-upstream origin ${prBranch}`); + do { + selectedPrs = await pickWhichPRsToUse(prs); + } while (selectedPrs.length === 0 && (console.warn("[WARNING]: must pick at least once commit"), true)); -const createPrProcess = execFile("gh", createPrArgs); + await cherrypickCommits(selectedPrs); -createPrProcess.child.stdout?.pipe(process.stdout); -createPrProcess.child.stderr?.pipe(process.stderr); + return selectedPrs; +} -createPrProcess.child.stdin?.write(prBody); -createPrProcess.child.stdin?.end(); +async function createRelease(): Promise { + await checkCurrentWorkingDirectory(); -await createPrProcess; + const currentK8slensCoreVersion = await getCurrentVersionOfSubPackage("core"); + const prBase = await getCurrentBranch(); + const isMasterBranch = prBase === "master"; + const tags = await fetchAllGitTags(); + const previousReleasedVersion = findClosestVersionTagLessThanVersion(tags, currentK8slensCoreVersion); + + if (isMasterBranch) { + await bumpPackageVersions(); + } + + const prMilestone = formatSemverForMilestone(await getCurrentVersionOfSubPackage("core")); + const relaventPrs = await getRelevantPRs(prMilestone, previousReleasedVersion); + const selectedPrs = await pickRelaventPrs(relaventPrs, isMasterBranch); + const prBody = formatChangelog(previousReleasedVersion, selectedPrs); + + if (!isMasterBranch) { + await bumpPackageVersions(); + } + + const newK8slensCoreVersion = await getCurrentVersionOfSubPackage("core"); + + await createReleaseBranchAndCommit(prBase, newK8slensCoreVersion, prBody); +} + +await createRelease();