|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | +Perform the next step to publish the extension. |
| 5 | + i. Verifies that it's on the master branch. If not so, terminate. Otherwise, pulls the latest master version. |
| 6 | +ii. If the last message on master indicates Version X.Y.Z and it does not have a tag. |
| 7 | + it means that steps iii. to vii. were executed correctly. |
| 8 | + Ask whether it's ready to publish (ENTER or y) |
| 9 | + - if so, create and publish the tag |
| 10 | + - Otherwise, cancel. |
| 11 | +
|
| 12 | +iii. Fetches all tags, and verify that there is no version tag. |
| 13 | + iv. Asks to bump the patch version (ENTER or p), the minor version (m) or the major version (M) |
| 14 | + v. Populates the CHANGELOG.md with the most recent commit messages. |
| 15 | + vi. If a newest version of Dafny is found, ask whether to refer to it as the latest (ENTER or y), or no (n) |
| 16 | +vii. Follows the steps of CONTRIBUTING. |
| 17 | + Ask to revise the CHANGELOG.md as needed |
| 18 | + (n or anything else) : Abruptly stop. |
| 19 | + (ENTER or y) : Creates a branch named "version-X.Y.Z" |
| 20 | + Commits the files to this branch with message "Version X.Y.Z" |
| 21 | + Push the branch. |
| 22 | + Indicate that the PR should be merge without changing the commit message. |
| 23 | + Indicate that the script just needs to be relaunched once the PR is approved and merged. |
| 24 | +*/ |
| 25 | + |
| 26 | +const fs = require("fs"); |
| 27 | +const { promisify, getSystemErrorMap } = require('util'); |
| 28 | +const exec = require('child_process').exec; |
| 29 | +const execAsync = promisify(exec); |
| 30 | +const readline = require('readline'); |
| 31 | +const fetch = (url, init) => |
| 32 | + import('node-fetch').then(({ default: fetch }) => fetch(url, init)); |
| 33 | +const rl = readline.createInterface({ |
| 34 | + input: process.stdin, |
| 35 | + output: process.stdout |
| 36 | +}); |
| 37 | +const question = function(input) { |
| 38 | + return new Promise((resolve, reject) => { |
| 39 | + rl.question(input, resolve); |
| 40 | + }); |
| 41 | +} |
| 42 | +const changeLogFile = "CHANGELOG.md"; |
| 43 | +const dafnyReleasesURL = 'https://api.github.com/repos/dafny-lang/dafny/releases'; |
| 44 | +const constantsFile = "src/constants.ts"; |
| 45 | +const packageFile = "package.json"; |
| 46 | +const packageLockFile = "package-lock.json"; |
| 47 | +const ABORTED = "ABORTED"; |
| 48 | +const ACCEPT_HINT = "(ENTER)"; |
| 49 | + |
| 50 | +function ok(answer) { |
| 51 | + return answer.toLowerCase() == "y" || answer == ""; |
| 52 | +} |
| 53 | + |
| 54 | +async function getCurrentBranch() { |
| 55 | + return (await execAsync("git branch --show-current")).stdout.trim(); |
| 56 | +} |
| 57 | + |
| 58 | +// Ensures that the working directory is clean |
| 59 | +async function ensureWorkingDirectoryClean() { |
| 60 | + var unstagedChanges = (await execAsync("git diff")).stdout.trim() + (await execAsync("git diff --cached")).stdout.trim(); |
| 61 | + if(unstagedChanges != "") { |
| 62 | + console.log("Please commit your changes before launching this script."); |
| 63 | + throw ABORTED; |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +async function ensureMaster() { |
| 68 | + await ensureWorkingDirectoryClean(); |
| 69 | + var currentBranch = getCurrentBranch(); |
| 70 | + if(currentBranch != "master") { |
| 71 | + console.log(`You need to be on the 'master' branch to release a new version.`); |
| 72 | + if(!ok(await question(`Switch from '${currentBranch}' to 'master'? ${ACCEPT_HINT}`))) { |
| 73 | + console.log("Publishing script aborted."); |
| 74 | + throw ABORTED; |
| 75 | + } |
| 76 | + console.log("switched to master branch"); |
| 77 | + console.log((await execAsync("git checkout master")).stdout); |
| 78 | + currentBranch = getCurrentBranch(); |
| 79 | + if(currentBranch != "master") { |
| 80 | + console.log("Failed to checkout master"); |
| 81 | + throw ABORTED; |
| 82 | + } |
| 83 | + } |
| 84 | + await execAsync("git pull"); |
| 85 | + console.log("Latest master checked out") |
| 86 | +} |
| 87 | + |
| 88 | +async function isTagMissing() { |
| 89 | + var tagList = (await execAsync("git show-ref --tags")).stdout.trim(); |
| 90 | + // recovers the last commit hash |
| 91 | + var lastCommitHash = (await execAsync("git log -1 --pretty=%H")).stdout.trim(); |
| 92 | + // checks if the last commit hash is in the tag list: |
| 93 | + var tagListRegex = new RegExp(`^${lastCommitHash}\\s*refs/tags/(\\w*)`); |
| 94 | + var match = tagListRegex.exec(tagList); |
| 95 | + if(match == null) { |
| 96 | + return true; |
| 97 | + } |
| 98 | + console.log(`The current master already has the tag ${match[1]}. Nothing needs to be done.\nIf you want to push the tag again, run 'git push --tags'`); |
| 99 | + throw ABORTED; |
| 100 | +} |
| 101 | + |
| 102 | +async function changeLogAndVersion() { |
| 103 | + let changeLog = await fs.promises.readFile(changeLogFile, "utf8"); |
| 104 | + const currentDocumentVersionRegex = /^#\s*Release Notes\s*##\s*(\d+.\d+.\d+)/; |
| 105 | + const match = currentDocumentVersionRegex.exec(changeLog); |
| 106 | + if(match == null) { |
| 107 | + console.log(`Could not find ${currentDocumentVersionRegex} in ${changeLogFile}`); |
| 108 | + throw ABORTED; |
| 109 | + } |
| 110 | + const currentChangeLogVersion = match[1]; |
| 111 | + const updateChangeLogWith = ((changeLog, oldVersion) => async function(newVersion, messages) { |
| 112 | + const newChangeLog = changeLog.replace(currentDocumentVersionRegex, match => |
| 113 | + `# Release Notes\n\n## ${newVersion}\n${messages}\n\n## ${oldVersion}`); |
| 114 | + await fs.promises.writeFile(changeLogFile, newChangeLog); |
| 115 | + return true; |
| 116 | + })(changeLog, currentChangeLogVersion); |
| 117 | + return {updateChangeLogWith, currentChangeLogVersion}; |
| 118 | +} |
| 119 | + |
| 120 | +async function getMostRecentDafnyRelease() { |
| 121 | + let mostRecentDafnyRelease = null; |
| 122 | + const dafnyReleases = await (await fetch(dafnyReleasesURL)).json(); |
| 123 | + for(var i = 0; i < dafnyReleases.length && mostRecentDafnyRelease == null; i++) { |
| 124 | + if(dafnyReleases[i].tag_name != "nightly") { |
| 125 | + mostRecentDafnyRelease = dafnyReleases[i].tag_name; |
| 126 | + break; |
| 127 | + } |
| 128 | + } |
| 129 | + if(mostRecentDafnyRelease == null) { |
| 130 | + console.log(`Could not fetch the latest Dafny release version from ${dafnyReleasesURL}`); |
| 131 | + throw ABORTED; |
| 132 | + } |
| 133 | + return mostRecentDafnyRelease; |
| 134 | +} |
| 135 | + |
| 136 | + |
| 137 | +async function readPackageJson() { |
| 138 | + const packageContent = await fs.promises.readFile(packageFile); |
| 139 | + const packageObj = JSON.parse(packageContent); |
| 140 | + return packageObj; |
| 141 | +} |
| 142 | + |
| 143 | +async function writePackage(packageObj) { |
| 144 | + await fs.promises.writeFile(packageFile, JSON.stringify(packageObj, null, 2)); |
| 145 | +} |
| 146 | + |
| 147 | +// returns the new version number |
| 148 | +async function nextVersion(currentVersion) { |
| 149 | + const currentVersionRegex = /(\d+)\.(\d+)\.(\d+)/; |
| 150 | + const match = currentVersionRegex.exec(currentVersion); |
| 151 | + if(match == null) { |
| 152 | + console.log(`Could not parse version ${currentVersion}`); |
| 153 | + throw ABORTED; |
| 154 | + } |
| 155 | + var bumpedPatch = `${match[1]}.${match[2]}.${parseInt(match[3]) + 1}`; |
| 156 | + var bumpedMinor = `${match[1]}.${parseInt(match[2]) + 1}.0`; |
| 157 | + var bumpedMajor = `${parseInt(match[1]) + 1}.0.0`; |
| 158 | + console.log("Should the next version be:"); |
| 159 | + console.log(`${currentVersion} => ${bumpedPatch}? ${ACCEPT_HINT}`); |
| 160 | + console.log(`${currentVersion} => ${bumpedMinor}? (m)`); |
| 161 | + var answer = await question(`${currentVersion} => ${bumpedMajor}? (M)\n`); |
| 162 | + if(ok(answer)) { |
| 163 | + return bumpedPatch; |
| 164 | + } else if(answer == "m") { |
| 165 | + return bumpedMinor; |
| 166 | + } else if(answer == "M") { |
| 167 | + return bumpedMajor; |
| 168 | + } else { |
| 169 | + console.log("Publishing script aborted."); |
| 170 | + throw ABORTED; |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +async function getLastPreparedTag() { |
| 175 | + const lastCommitMessage = (await execAsync("git log -1 --pretty=%B")).stdout.trim(); |
| 176 | + const lastCommitMessageRegex = /(v\d+\.\d+\.\d+)/; |
| 177 | + const match = lastCommitMessageRegex.exec(lastCommitMessage); |
| 178 | + if(match == null) { |
| 179 | + return false; |
| 180 | + } |
| 181 | + return match[1]; |
| 182 | +} |
| 183 | + |
| 184 | +function getCommandLine() { |
| 185 | + switch (process.platform) { |
| 186 | + case 'darwin' : return 'open'; |
| 187 | + case 'win32' : return 'start'; |
| 188 | + case 'win64' : return 'start'; |
| 189 | + default : return 'xdg-open'; |
| 190 | + } |
| 191 | +} |
| 192 | + |
| 193 | +async function getAllRecentCommitMessagesFormatted(currentChangeLogVersion) { |
| 194 | + // Query git for all the commit messages from currentChangeLogVersion (excluded) to the latest commit (included) |
| 195 | + var raw = (await execAsync(`git log --pretty=%B v${currentChangeLogVersion}..HEAD`)).stdout.trim(); |
| 196 | + raw = "- " + raw.trim().replace(/\n\s*/g, "\n- "); |
| 197 | + raw = raw.replace(/\(#(\d+)\)/g, "(https://github.com/dafny-lang/ide-vscode/pull/$1)"); |
| 198 | + raw = raw.trim(); |
| 199 | + raw = raw.replace(/\n.*$/, ""); // Removes the last one, because it's the last release commit. |
| 200 | + return raw.trim(); |
| 201 | +} |
| 202 | + |
| 203 | +function close() { |
| 204 | + rl.close(); |
| 205 | + return false; |
| 206 | +} |
| 207 | + |
| 208 | +async function updatePackageJson(packageObj, newVersion, mostRecentDafnyRelease) { |
| 209 | + packageObj["version"] = newVersion; |
| 210 | + var versionList = packageObj["contributes"]["configuration"]["properties"]["dafny.preferredVersion"]["enum"]; |
| 211 | + // versionList starts with "latest", and then the last version |
| 212 | + var previousDafnyVersion = versionList[1]; |
| 213 | + var updatedDafny = false; |
| 214 | + if (previousDafnyVersion != mostRecentDafnyRelease) { |
| 215 | + if (ok(await question(`The Dafny version in the package.json file (${previousDafnyVersion}) is not the latest (${mostRecentDafnyRelease}). Do you want to update it? ${ACCEPT_HINT}`))) { |
| 216 | + var previousDafnyVersionListHead = versionList[1]; |
| 217 | + // If the previous dafny version is just different from mostRecentDafnyRelease by the patch number, replace it, otherwise insert it using splice |
| 218 | + if (previousDafnyVersionListHead == mostRecentDafnyRelease.substring(0, mostRecentDafnyRelease.lastIndexOf("."))) { |
| 219 | + versionList[1] = mostRecentDafnyRelease; |
| 220 | + } else { |
| 221 | + versionList.splice(1, 0, mostRecentDafnyRelease); |
| 222 | + } |
| 223 | + |
| 224 | + console.log("Updated Dafny version to " + mostRecentDafnyRelease); |
| 225 | + var constantsContent = await fs.promises.readAsync(constantsFile, "utf8"); |
| 226 | + var constantsContentRegex = /const\s*LatestVersion\s*=\s*'\d+.\d+.\d+';/; |
| 227 | + constantsContent.replace(constantsContentRegex, `const LatestVersion = '${mostRecentDafnyRelease}';`); |
| 228 | + await fs.promises.writeAsync(constantsFile, constantsContent); |
| 229 | + updatedDafny = true; |
| 230 | + } else { |
| 231 | + console.log("Ignoring new Dafny version."); |
| 232 | + } |
| 233 | + } |
| 234 | + await writePackage(packageObj); |
| 235 | + return updatedDafny; |
| 236 | +} |
| 237 | + |
| 238 | +async function UpdateChangeLog(currentChangeLogVersion, packageObj, updateChangeLogWith, newVersion) { |
| 239 | + var allRecentCommitMessages = await getAllRecentCommitMessagesFormatted(currentChangeLogVersion); |
| 240 | + if (packageObj["version"] == currentChangeLogVersion) { |
| 241 | + await updateChangeLogWith(newVersion, allRecentCommitMessages); |
| 242 | + console.log("I changed " + changeLogFile + " to reflect the new version.\nPlease make edits as needed and close the editing window."); |
| 243 | + await execAsync(getCommandLine() + ' ' + changeLogFile); |
| 244 | + if (!await question(`Ready to continue? ${ACCEPT_HINT}`)) { |
| 245 | + console.log("Aborting."); |
| 246 | + throw ABORTED; |
| 247 | + } |
| 248 | + currentChangeLogVersionCheck = (await changeLogAndVersion()).currentChangeLogVersion; |
| 249 | + if (currentChangeLogVersionCheck != newVersion) { |
| 250 | + console.log(`The last version was supposed to be ${newVersion}, but the changelog was updated to ${currentChangeLogVersionCheck}. Aborting publishing.`); |
| 251 | + throw ABORTED; |
| 252 | + } |
| 253 | + } else { |
| 254 | + console.log("ChangeLog.md already up-to-date"); |
| 255 | + } |
| 256 | + return answer; |
| 257 | +} |
| 258 | + |
| 259 | +async function HandleFinalPublishingProcess(currentChangeLogVersion, lastPreparedTag) { |
| 260 | + if ("v" + currentChangeLogVersion == lastPreparedTag) { |
| 261 | + // Tag the current commit |
| 262 | + console.log(`The changelog already mentions version ${currentChangeLogVersion}.\nYou now need to create the tag ${lastPreparedTag} and publish it to release this new version.`); |
| 263 | + // ask for confirmation, and publish the tag. |
| 264 | + if (ok(await question(`Create and publish the tag ${lastPreparedTag}? ${ACCEPT_HINT}`))) { |
| 265 | + console.log(`Creating tag ${lastPreparedTag}...`); |
| 266 | + await execAsync(`git tag ${lastPreparedTag}`); |
| 267 | + console.log(`Publishing tag ${lastPreparedTag}...`); |
| 268 | + await execAsync(`git push origin ${lastPreparedTag}`); |
| 269 | + console.log(`${lastPreparedTag} published. The CI will take care of releasing the new VSCode extension.`); |
| 270 | + } else { |
| 271 | + console.log("Just run the script again when you are ready to publish the version. Aborting."); |
| 272 | + throw ABORTED; |
| 273 | + } |
| 274 | + } else { |
| 275 | + console.log("Something went wrong. I found " + lastPreparedTag + " in the last commit message, and this tag is not published yet."); |
| 276 | + console.log("However, the changelog mentions a different version:" + currentChangeLogVersion); |
| 277 | + console.log("Please fix the current state"); |
| 278 | + throw ABORTED; |
| 279 | + } |
| 280 | +} |
| 281 | + |
| 282 | +async function Main() { |
| 283 | + try { |
| 284 | + // verify that we are on the master branch. |
| 285 | + await ensureMaster(); |
| 286 | + await isTagMissing(); |
| 287 | + let {updateChangeLogWith, currentChangeLogVersion} = await changeLogAndVersion(); |
| 288 | + |
| 289 | + const lastPreparedTag = await getLastPreparedTag(); |
| 290 | + if(lastPreparedTag) { |
| 291 | + // Here we only need to publish the last prepared tag |
| 292 | + await HandleFinalPublishingProcess(currentChangeLogVersion, lastPreparedTag); |
| 293 | + return; |
| 294 | + } |
| 295 | + |
| 296 | + let newVersion = await nextVersion(currentChangeLogVersion); |
| 297 | + let mostRecentDafnyRelease = await getMostRecentDafnyRelease().substring(1); |
| 298 | + let packageObj = await readPackageJson(); |
| 299 | + |
| 300 | + console.log(`Going to proceed to publish ${newVersion}`); |
| 301 | + // Get all the commit messages since the last published tag |
| 302 | + await UpdateChangeLog(currentChangeLogVersion, packageObj, updateChangeLogWith, newVersion); |
| 303 | + // All clear, we can modify constants.ts and package.json. |
| 304 | + |
| 305 | + var updatedDafny = await updatePackageJson(packageObj, newVersion, mostRecentDafnyRelease); |
| 306 | + // Execute npm install to ensure the package lock is up to date |
| 307 | + console.log("Executing `npm install`..."); |
| 308 | + await execAsync("npm install"); |
| 309 | + |
| 310 | + // Create the new branch and git add all the files modified above |
| 311 | + console.log("Creating new branch..."); |
| 312 | + const newBranch = `release-${newVersion}`; |
| 313 | + await execAsync(`git checkout -b ${newBranch}`); |
| 314 | + await execAsync(`git add ${changeLogFile} ${packageFile} ${constantsFile} ${packageLockFile}`); |
| 315 | + if(ok(await question(`I made all the necessary edits. Push the changes to the remote repository? ${ACCEPT_HINT}`))) { |
| 316 | + await execAsync(`git commit -m "Release v${newVersion}${ updatedDafny ? ` (updated Dafny to ${mostRecentDafnyRelease})` : "" }"`); |
| 317 | + await execAsync(`git push origin ${newBranch}`); |
| 318 | + console.log("Now, create the pull request by clicking the link below:"); |
| 319 | + console.log(`https://github.com/dafny-lang/ide-vscode/compare/${newBranch}?expand=1`); |
| 320 | + console.log("When this PR is approved and merged, launch this script again to finish publishing the release."); |
| 321 | + } else { |
| 322 | + console.log("Aborting publishing."); |
| 323 | + return close(); |
| 324 | + } |
| 325 | + |
| 326 | + } catch(e) { |
| 327 | + if(e != ABORTED) { |
| 328 | + throw e; |
| 329 | + } |
| 330 | + } finally { |
| 331 | + close(); |
| 332 | + } |
| 333 | +} |
| 334 | +Main(); |
| 335 | + |
0 commit comments