diff --git a/doc/contributing/releases.md b/doc/contributing/releases.md index 3503c68461..b417e19757 100644 --- a/doc/contributing/releases.md +++ b/doc/contributing/releases.md @@ -106,75 +106,13 @@ The RxPlayer has several types of releases: When published on `npm`, they have the `canal` tag. -## Workflow for an official release +## To publish an official release -Before each official releases, a list of steps are performed by its maintainers: +Before publishing an official RxPlayer releases, a list of steps should be performed by +its maintainers. -1. Checkout the branch that will be the base of the next release: generally it is either - `stable` (for patch releases) or `dev` (for minor versions). +First, checkout the branch that will be the base of the next release: generally it is +either `stable` (for patch releases) or `dev` (for minor and major versions). -2. Rebase that branch on the `stable` branch if it is a different branch (e.g.: - `git rebase stable --rebase-merges`). - -3. From there, create a branch named `release/vXX.XX.XX`, where XX.XX.XX is the semver of - the wanted new version. - -4. Update `CHANGELOG.md` file to add this new version's changelog and commit it. - -5. Call the npm script `update-version` for that release - (`npm run update-version XX.XX.XX`, where XX.XX.XX is the wanted new version SEMVER). - -6. Check that the modifications it did make sense and create a signed commit (`-S` option - when commiting with git) updating the staged built files in step `8`. - -7. Open a Pull Request on Github, named after the branch, putting the release's changelog - in the Pull Request's comment, and optional additional comments. - -8. Check that sonarcloud validated that branch. Resolve every bug and code smells it - finds. - -9. Ensure that the CI doesn't detect any issue and fix them if that's the case. - -10. Run sanity checks on myCanal's repository, by using this new version instead as a - dependency. - -11. If and only if no problem was seen perform a signed merge without fast-forward of the - release branch into master - -12. Launch script to update the gh-pages demo (`./scripts/update_gh-pages_demo`) - -13. Launch script to update the gh-pages doc (`./scripts/update_gh-pages_doc`) - -14. Check that both of those worked, perform manual updates and update the concerned - scripts in other cases. - -15. Check that the new demo and the new doc work as expected - -16. If all seems good, push to origin/stable your local stable branch. - -17. run `npm publish` to publish the new version on npm's registry. - -18. Test that importing this new version doesn't cause bundling issues (you may do so - through a ad-hoc package, or just myCanal for example). - -19. Create the new release through github's interface - don't forget to include the built - files on it. - -If any of the testing steps failed (after step `3`), run the following steps: - -1. Fix the problem (!) - -2. Create a commit for the fix with a meaningful message. - -3. If (and only if) it make sense, update the changelog and create a commit for it. - -4. Call the npm script `update-version` for that release - (`npm run update-version XX.XX.XX`, where XX.XX.XX is the wanted new version SEMVER). - -5. Check that the modifications of that last step made sense and create a signed commit - (`-S` option when commiting with git) updating the staged built files. - -6. Depending on the nature of the fix, either create a new branch and add a Pull Request - to merge it in the release branch or push it to the release branch directly. - -7. Go back to step `8` (sonarcloud + CI) of the previous workflow +Then, the following steps are mostly automatized by the `releases:official` script, which +may be run by calling `npm run releases:official`. diff --git a/package.json b/package.json index c6884410c3..1fdd589ef5 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,11 @@ "lint:tests": "eslint tests/**/*.js --ignore-pattern '/tests/performance/bundle*'", "list": "node scripts/list-npm-scripts.mjs", "prepublishOnly": "npm run build:all", + "releases:changelog": "./scripts/update_changelog.mjs", + "releases:demo": "./scripts/update_gh-pages_demo", "releases:dev": "./scripts/make-dev-releases", + "releases:doc": "./scripts/update_gh-pages_doc", + "releases:official": "./scripts/make-official-release.sh", "start": "node ./scripts/start_demo_web_server.mjs", "start:wasm": "node ./scripts/start_demo_web_server.mjs --include-wasm", "test:integration": "npm run test:integration:chrome && npm run test:integration:firefox", @@ -236,7 +240,8 @@ "check:demo": "Check the validity of the demo directory by running the type checker and linter on it", "check:demo:types": "Check TypeScript types in demo files", "lint:demo": "Run linter on demo files", - "certificate": "Generate a certificate to be able to use HTTPS locally for the demo pages (`npm run start` will then listen to HTTPS requests through a communicated port)" + "certificate": "Generate a certificate to be able to use HTTPS locally for the demo pages (`npm run start` will then listen to HTTPS requests through a communicated port)", + "releases:demo": "Publish current demo as the GitHub's pages new demo page (\"stable\" branch only)" }, "Type-check, format, or lint the current code": { "check": "Check the validity of the src directory by running the type checker and linter on it", @@ -288,11 +293,13 @@ } }, "Build the documentation pages": { - "doc": "Generate the HTML documentation in doc/generated/pages" + "doc": "Generate the HTML documentation in doc/generated/pages", + "releases:doc": "Publish current documentation as the GitHub's pages new documentation pages (\"stable\" branch only)" }, "Make a release": { - "releases:dev": "Produce dev npm releases (which are tagged pre-releases on npm) with the code in the current branch", - "update-version": "Update the version to the string given in argument (example: `npm run update-version 3.8.0`). Will update the codebase and perform every builds." + "releases:dev": "Produce dev npm releases (which are tagged pre-releases on npm) from the current branch.", + "releases:official": "Produce a new official release of the RxPlayer from the current branch.", + "update-version": "Update the version of the RxPlayer. Will update the codebase and perform every builds." } } } diff --git a/scripts/make-dev-releases b/scripts/make-dev-releases index dee03c55ee..28d8919b68 100755 --- a/scripts/make-dev-releases +++ b/scripts/make-dev-releases @@ -34,12 +34,21 @@ set -e +# Log a line to sterr and exit with error code 1 +err() { + echo "ERROR: $1" >&2 + exit 1 +} + if [ $# -eq 0 ]; then read -r -p "Please enter the wanted version number (example: 4.12.1): " version + echo "" if [ -z "${version}" ]; then - echo "Please enter a valid version number" - exit 1 + # TODO SEMVER REGEX? + err "Please enter a valid version number next time." fi +else + version=$1 fi if [ $# -lt 2 ]; then @@ -47,12 +56,96 @@ if [ $# -lt 2 ]; then if [ -z "${incr}" ]; then incr="00" fi +else + incr=$2 fi current_branch=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p') date=$(date "+%Y%m%d") -dev_branch="release/v${version}-dev.${date}${incr}" -canal_branch="release/v${version}-canal.${date}${incr}" +dev_version="${version}-dev.${date}${incr}" +canal_version="${version}-canal.${date}${incr}" +dev_branch="release/v${dev_version}" +canal_branch="release/v${canal_version}" + +if [ -n "$(git status --porcelain)" ]; then + err "There is unstaged changes in your worktree. Please commit your changes or stash them before creating the release." +fi + +echo "This script will create the dev versions: $dev_version and $canal_version" + +echo "checking that the branches do not already exist locally or remotely..." +if ! [ -z $(git branch --list "$dev_branch") ]; then + err "Branch name \"$dev_branch\" already exists locally. Please delete it first." +fi +if ! [ -z $(git branch --list "$canal_branch") ]; then + err "Branch name \"$canal_branch\" already exists locally. Please delete it first." +fi +if ! [ -z $(git ls-remote --heads git@github.com:canalplus/rx-player.git "refs/heads/$dev_branch") ]; then + err "Branch name \"$dev_branch\" already exists remotely. Please delete it first." +fi +if ! [ -z $(git ls-remote --heads git@github.com:canalplus/rx-player.git "refs/heads/$canal_branch") ]; then + err "Branch name \"$canal_branch\" already exists remotely. Please delete it first." +fi + +echo "checking that the versions are not already published on npm..." +if npm view rx-player@$dev_version >/dev/null 2>&1; then + err "Version already published to npm: $version-dev.${date}${incr}" +fi + +if npm view rx-player@$canal_version >/dev/null 2>&1; then + err "Version already published to npm: $version-canal.${date}${incr}" +fi + +# Make dev Changelog +npm run releases:changelog -- $dev_version -d + +$EDITOR CHANGELOG.md + +if [ -n "$(git status --porcelain CHANGELOG.md)" ]; then + echo "-- Current CHANGELOG.md Status: --" + echo "" + git status CHANGELOG.md + + while :; do + echo "" + echo "We will push this CHANGELOG.md update to ${current_branch}." + read -p "do you want to continue [y/d/s/a/c/t/h] (h for help) ? " -n1 REPLY + echo "" + + if [[ $REPLY =~ ^[Hh](elp)?$ ]]; then + echo "" + echo "" + echo "+- help -------------------------------------------------+" + echo "| y: commit and continue |" + echo "| d: see diff |" + echo "| s: see status |" + echo "| a: abort script from here |" + echo "| c: skip CHANGELOG.md update and go to the next step |" + echo "| h: see this help |" + echo "+--------------------------------------------------------+" + elif [[ $REPLY =~ ^[Yy](es)?$ ]]; then + git add CHANGELOG.md + git commit -m "Update CHANGELOG.md for v$dev_version" + git push git@github.com:canalplus/rx-player.git $current_branch + break + elif [[ $REPLY =~ ^[Dd](iff)?$ ]]; then + git diff CHANGELOG.md || true # ignore when return 1 + elif [[ $REPLY =~ ^[Ss](tatus)?$ ]]; then + git status CHANGELOG.md + elif [[ $REPLY =~ ^[Aa](bort)?$ ]]; then + echo "exiting" + exit 0 + elif [[ $REPLY =~ ^[Cc](heckout)?$ ]]; then + git checkout CHANGELOG.md + else + echo "invalid input" + fi + done +fi + +if [ -n "$(git status --porcelain)" ]; then + err "Unexpected diff in \"${current_branch}\"" +fi git checkout -b ${dev_branch} ./scripts/update-version $version-dev.${date}${incr} @@ -62,9 +155,9 @@ while true; do read -n1 -p "Do you wish to push and publish the dev build? [y/n] " yn echo "" case $yn in - [Yy]* ) break;; - [Nn]* ) exit;; - * ) echo "Please answer y or n.";; + [Yy]*) break ;; + [Nn]*) exit ;; + *) echo "Please answer y or n." ;; esac done git push origin ${dev_branch} @@ -82,9 +175,9 @@ while true; do read -n1 -p "Do you wish to push and publish the canal build? [y/n] " yn echo "" case $yn in - [Yy]* ) break;; - [Nn]* ) exit;; - * ) echo "Please answer y or n.";; + [Yy]*) break ;; + [Nn]*) exit ;; + *) echo "Please answer y or n." ;; esac done npm publish --tag canal-v4 diff --git a/scripts/make-official-release.sh b/scripts/make-official-release.sh new file mode 100755 index 0000000000..9fba507cd2 --- /dev/null +++ b/scripts/make-official-release.sh @@ -0,0 +1,354 @@ +#!/bin/bash + +# Make official release +# ===================== +# +# This script helps RxPlayer developers to make a new official RxPlayer release. +# +# To use it: +# +# 1. Be sure that you're either on the `stable` branch (if this is a "patch" +# release in terms of SEMVER) or on the `dev` branch (if this is a "minor" +# SEMVER release) +# +# 2. Call this script, you may optionally provide the wanted version as an +# argument to this script. If no argument is provided, the script will ask +# for your wanted version number. +# +# 3. Wait for all the commands to finish and stay attentive to the output. It +# will ask you for inputs at various steps and tell you what you need to +# know to make a release. + +set -e + +# Log a line prefixed with our script's name +log() { + echo "---- RxPlayer Release Script ---- $1" +} + +# Log a line prefixed with our script's name after an empty line then sleep a little +emphasized_log() { + echo "" + log "$1" + sleep 3.8 +} + +# Log a line to sterr and exit with error code 1 +err() { + echo "ERROR: $1" >&2 + exit 1 +} + +# Check that the given command is installed and quit on error if that's not the case +check_dependency() { + if [ -z $(command -v $1) ]; then + err "This script needs \"$1\" to be installed and be executable" + fi +} + +# Get the name of the current git branch +current_branch() { + echo $(git branch | sed -n -e 's/^\* \(.*\)/\1/p') +} + +# Get the local name for canalplus's remote repository +git_remote_name() { + git remote -v | grep "git@github.com:canalplus/rx-player.git" | grep "(push)" | cut -f1 +} + +# Check that the current branch is up-to-date with remote, errors if that's not +# the case +check_branch_synchronized_with_remote() { + checked_branch=$(current_branch) + checked_remote=$(git_remote_name) + if ! [ x"$(git rev-parse $checked_branch)" = x"$(git rev-parse $checked_remote/$checked_branch)" ]; then + err "The branch \"$checked_branch\" is not synchronized with the remote \"$checked_remote\". Please synchronize it first." + fi +} + +check_dependency git +check_dependency echo +check_dependency npm +check_dependency cut +check_dependency grep +check_dependency sed +check_dependency sleep + +base_branch=$(current_branch) + +if ! [ "$base_branch" == "dev" ] && ! [ "$base_branch" == "stable" ]; then + err "The base branch for releases should be either \"dev\" or \"stable\"" +fi + +if [ -n "$(git status --porcelain)" ]; then + err "Please commit your modifications first" +fi + +echo "Checking current branch is synchronized with remote..." +check_branch_synchronized_with_remote + +if [ $# -eq 0 ]; then + read -r -p "Please enter the wanted version number (example: 4.12.1): " version + echo "" + if [ -z "${version}" ]; then + # TODO SEMVER REGEX? + err "Please enter a valid version number next time." + fi +else + version=$1 +fi + +emphasized_log "This script will create the official version: $version" + +echo "checking that the branche does not already exist locally or remotely..." +if ! [ -z $(git branch --list "release/v$version") ]; then + err "Branch name "release/v$version" already exists locally. Please delete it first." +fi + +if ! [ -z $(git ls-remote --heads git@github.com:canalplus/rx-player.git "refs/heads/release/v$version") ]; then + err "Branch name "release/v$version" already exists remotely. Please delete it first." +fi + +echo "checking that the version are not already published on npm..." +if npm view rx-player@$version >/dev/null 2>&1; then + err "Version already published to npm: $version" +fi + +if [ "$base_branch" == "dev" ]; then + emphasized_log "Checkout the stable branch and pull it..." + git checkout stable + git pull git@github.com:canalplus/rx-player.git stable + + if [ -n "$(git status --porcelain)" ]; then + err "Please commit your modifications first" + fi + + check_branch_synchronized_with_remote + + emphasized_log "Rebase the dev branch on stable..." + git checkout dev + git rebase stable --rebase-merges +fi + +if [ -n "$(git status --porcelain)" ]; then + err "Error after doing rebases: updated files" +fi + +# Make Changelog +npm run releases:changelog -- $version + +$EDITOR CHANGELOG.md + +if [ -n "$(git status --porcelain CHANGELOG.md)" ]; then + echo "-- Current CHANGELOG.md Status: --" + echo "" + git status CHANGELOG.md + + while :; do + echo "" + echo "We will push this CHANGELOG.md update to $base_branch." + read -p "do you want to continue [y/d/s/a/c/t/h] (h for help) ? " -n1 REPLY + echo "" + + if [[ $REPLY =~ ^[Hh](elp)?$ ]]; then + echo "" + echo "" + echo "+- help -------------------------------------------------+" + echo "| y: commit and continue |" + echo "| d: see diff |" + echo "| s: see status |" + echo "| a: abort script from here |" + echo "| c: skip CHANGELOG.md update and go to the next step |" + echo "| h: see this help |" + echo "+--------------------------------------------------------+" + elif [[ $REPLY =~ ^[Yy](es)?$ ]]; then + git add CHANGELOG.md + git commit -m "Update CHANGELOG.md for v$version" + git push git@github.com:canalplus/rx-player.git $base_branch + break + elif [[ $REPLY =~ ^[Dd](iff)?$ ]]; then + git diff CHANGELOG.md || true # ignore when return 1 + elif [[ $REPLY =~ ^[Ss](tatus)?$ ]]; then + git status CHANGELOG.md + elif [[ $REPLY =~ ^[Aa](bort)?$ ]]; then + echo "exiting" + exit 0 + elif [[ $REPLY =~ ^[Cc](heckout)?$ ]]; then + git checkout CHANGELOG.md + else + echo "invalid input" + fi + done +fi + +if [ -n "$(git status --porcelain doc)" ]; then + echo "ERROR: Unexpected diff in \"$base_branch\"" + exit 1 +fi + +emphasized_log "Creating \"release/v$version\" branch..." +git checkout -b "release/v$version" + +emphasized_log "Calling update-version script to update files and produce builds..." +npm run update-version $version + +if [ -n "$(git status --porcelain)" ]; then + echo "" + log "Current $base_branch branch status:" + git status + + while :; do + echo "" + log "We will push the following modification to a new release/v$version branch." + REPLY="" + read -p "do you want to continue [y/d/s/a/c/t/h] (h for help) ? " -n 1 -r + echo "" + + if [[ $REPLY =~ ^[Hh](elp)?$ ]]; then + log "+- help -------------------------------------------------+" + log "| y: commit, push and continue |" + log "| d: see diff |" + log "| s: see status |" + log "| a: abort script from here |" + log "| h: see this help |" + log "+--------------------------------------------------------+" + elif [[ $REPLY =~ ^[Yy](es)?$ ]]; then + if ! [ $(current_branch) == "release/v$version" ]; then + err "The current branch is not \"release/v$version\"" + fi + emphasized_log "Commiting those updates..." + git add --all + git commit -m "update version to $version" + break + elif [[ $REPLY =~ ^[Dd](iff)?$ ]]; then + git diff || true # ignore when return 1 + elif [[ $REPLY =~ ^[Ss](tatus)?$ ]]; then + git status + elif [[ $REPLY =~ ^[Aa](bort)?$ ]]; then + log "exiting" + exit 0 + fi + done +else + log "nothing to do on the release branch" +fi + +$EDITOR CHANGELOG.md +if [ -n "$(git status --porcelain)" ]; then + emphasized_log "Commiting CHANGELOG.md update..." + git add CHANGELOG.md + git commit -m "Update CHANGELOG.md for v$version" +fi + +emphasized_log "Pushing \"release/v$version\" branch to GitHub..." +git push git@github.com:canalplus/rx-player.git "release/v$version" + +while :; do + echo "" + log "~~~~~~~~~~~~~~~~~~~~~~~~~ RxPlayer Release Script ~~~~~~~~~~~~~~~~~~~~~~~~~" + log "" + log "Your release branch has been pushed to release/v$version" + log "Please open a Pull Request on GitHub's interface for it and ensure the CI" + log "passes." + log "" + log "If the CI fails, you can fix it directly on that release branch, keeping that" + log "script pending." + log "" + log 'Once the CI passes, type "y"' + log "" + log "If this script has to be interrupted before the CI passes, please delete the" + log "remote and local release branch before calling this script again." + log "" + log "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" + REPLY="" + read -p "" -n 1 -r + echo "" + + if [[ $REPLY =~ ^[Yy](es)?$ ]]; then + break + fi +done + +while :; do + emphasized_log "Merging \"release/v$version\" branch to \"stable\" branch..." + git checkout stable + git merge -S --no-ff "release/v$version" stable + + emphasized_log "Running \"releases:demo\" script to update the gh-pages' demo..." + npm run releases:demo + + emphasized_log "Running \"releases:doc\" script to update the gh-pages' documentation..." + npm run releases:doc + echo "" + log "~~~~~~~~~~~~~~~~~~~~~~~~~ RxPlayer Release Script ~~~~~~~~~~~~~~~~~~~~~~~~~" + log "" + log "The demo page:" + log "https://developers.canal-plus.com/rx-player/" + log "" + log "And the documentation pages:" + log "https://developers.canal-plus.com/rx-player/doc/api/Overview.html" + log "" + log "Have just been updated (actual deployment may take several minutes, please" + log "check the anounced version on both pages first)." + log "" + log "Check that everything is working as intended." + log "" + log 'If those pages are OK, type "y"' + log "" + log 'If one of those pages has an issue, type "r"' + log "" + log "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" + REPLY="" + read -p "" -n 1 -r + echo "" + + if [[ $REPLY =~ ^[Yy](es)?$ ]]; then + emphasized_log "Pushing \"stable\" branch to GitHub..." + git push git@github.com:canalplus/rx-player.git stable + break + elif [[ $REPLY =~ ^[Rr](ewind)?$ ]]; then + if ! [ $(current_branch) == "stable" ]; then + err "The current branch is not \"stable\"" + fi + emphasized_log "Resetting \"stable\" branch and checkouting \"release/v$version\" branch again..." + check_branch_synchronized_with_remote + git reset --hard HEAD~1 + git checkout "release/v$version" + while :; do + echo "" + log "~~~~~~~~~~~~~~~~~~~~~~~~~ RxPlayer Release Script ~~~~~~~~~~~~~~~~~~~~~~~~~" + log "" + log "We switched back to the branch: release/v$version" + log "" + log "Please fix the seen issues there, then ensure the CI passes." + log "" + log "If the CI fails, you can fix it directly on that release branch, keeping that" + log "script pending." + log "" + log 'Once the CI passes, type "y"' + log "" + log "If this script has to be interrupted before the CI passes, please delete the" + log "remote and local release branch before calling this script again." + log "" + log "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" + REPLY="" + read -p "" -n 1 -r + echo "" + + if [[ $REPLY =~ ^[Yy](es)?$ ]]; then + break + fi + done + fi +done + +echo "" +log "~~~~~~~~~~~~~~~~~~~~~~~~~ RxPlayer Release Script ~~~~~~~~~~~~~~~~~~~~~~~~~" +log "" +log "The stable branch has been updated to now point to the v$version release and" +log "has been pushed to remote." +log "" +log 'You may now run "npm publish", check the published package, and then create' +log "the release on GitHub's interface (don't forget to include builds in it)." +log "" +log "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" diff --git a/scripts/update-version b/scripts/update-version index 58e5cd378f..2f1b299ed8 100755 --- a/scripts/update-version +++ b/scripts/update-version @@ -6,14 +6,14 @@ # This script updates the version number in files where it is indicated and # builds the rx-player for this new version. # -# /!\ It does not create a new git tag +# /!\ It does not create a new git tag, nor commit anything. It only update +# files and produce builds. # # To use it: # -# 1. Be sure that you're on the branch corresponding to the next version. +# 1. Be sure that you're on the branch corresponding to the wanted version. # -# 2. Call this script followed with the version number in the -# `MAJOR.MINOR.PATCH` format (e.g. `./update-version 3.22.0`). +# 2. Call this script. # # 3. Wait for all the script to finish and stay attentive to what it says # @@ -22,11 +22,16 @@ set -e if [ $# -eq 0 ]; then - echo "no version in argument" - exit 1 + read -r -p "Please enter the wanted version number (example: 4.12.1): " version + echo "" + if [ -z "${version}" ]; then + # TODO SEMVER REGEX? + err "Please enter a valid version number next time." + fi +else + version=$1 fi -version=$1 date_iso=$(date "+%Y-%m-%d") sed -i.bak -E -e "s/^\#\# Unreleased/\#\# v${version} \(${date_iso}\)/gi" CHANGELOG.md && rm CHANGELOG.md.bak diff --git a/scripts/update_changelog.mjs b/scripts/update_changelog.mjs new file mode 100755 index 0000000000..09f7c7b35a --- /dev/null +++ b/scripts/update_changelog.mjs @@ -0,0 +1,558 @@ +#!/usr/bin/env node +/** + * # update_changelog.mjs + * + * This file tries to automatically update our `CHANGELOG.md` file, by relying + * on the `version` given as argument and on the body of merge commits added + * since the last time `CHANGELOG.md` was updated according to `git`. + * Because there may be a lot of false positives and negatives with this method, + * `CHANGELOG.md`` is then opened with the program referenced by the `$EDITOR` + * environment variable so the user can perform ajustments. + * + * You can either run it directly as a script (run `node update_changelog.mjs -h` + * to see the different options) or by requiring it as a node module. + * If doing the latter you will obtain a function you will have to run with the + * right options. + */ + +import * as fs from "fs"; +import readline from "readline"; +import { dirname, join } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; +import { exec } from "child_process"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_CHANGELOG_PATH = join(currentDir, "../CHANGELOG.md"); + +const NOTICE_OPENER_CLOSER = "---"; +const NOTICE_PREFIX = `⚠️ The following "proposed additional changelog lines" were automatically +generated by our script by listing all merge commits since that file's last +updates. +It may have missed some. + +You're expected to categorize, when pertinent, the following lines (and possibly +the other features we missed) by adding them into categories (\`# Features\` etc.), +like done for other versions below. +You're free to reword them so it make more sense in this changelog. + +Once done, don't forget to remove this notice before exiting your editor. +The resulting file will be the one commited. + +### Proposed additional changelog lines: +`; +// const NOTICE_EMPTY = `No merge request found since last changelog update.`; + +// XXX TODO?: +// - We didn't find any merge commit since the last time the changelog file was +// udated. Do you want to skip manual changelog editing (only if no update +// since last release) + +// If true, this script is called directly +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const options = process.argv.slice(2); + + let version; + let isDevRelease = false; + for (const option of options) { + if (option.startsWith("-")) { + if (option === "-d" || option === "-dev") { + isDevRelease = true; + } else if (option === "-h" || option === "--help") { + displayHelp(); + process.exit(0); + } else { + console.error("ERROR: Unrecognized option:", option); + console.error( + "More details on usage by calling node update_changelog.mjs --help", + ); + process.exit(1); + } + } else if (version === undefined) { + version = option; + } else { + console.error("ERROR: Unrecognized option:", option); + console.error("More details on usage by calling node update_changelog.mjs --help"); + process.exit(1); + } + } + + if (version === undefined) { + console.error("ERROR: Missing version argument."); + console.error("More details on usage by calling node update_changelog.mjs --help"); + process.exit(1); + } + + try { + const version = process.argv[2]; + updateChangelog({ version, isDevRelease }).catch((err) => { + console.error("ERROR:", err); + process.exit(1); + }); + } catch (err) { + console.error("ERROR:", err); + process.exit(1); + } +} + +/** + * @param {Object} arg + * @param {string} arg.version - The version you want to add to the changelog + * @param {boolean} arg.isDevRelease - If `true`, this is only a dev release. It + * will have a special syntax in the changelog. + * @returns {Promise} + */ +export default async function updateChangelog({ + version, + isDevRelease, + changelogPath = DEFAULT_CHANGELOG_PATH, +}) { + console.log(`Trying to automatically update the CHANGELOG at: ${changelogPath}`); + const readStream = fs.createReadStream(changelogPath); + const newTopH2 = isDevRelease ? `## Current dev build: v${version}` : `## v${version}`; + + let currentData = ""; + for await (const chunk of readStream) { + currentData += String(chunk); + + let offset = 0; + + // Skip whitespace at the very beginning + offset = skipWhileChar(currentData, offset, [" ", "\n", "\r"]); + if (currentData.length <= offset + 2) { + continue; + } + + // Skip h1 + { + assert( + currentData.slice(offset, offset + 2) === "# ", + `Unexpected characters "${currentData.slice(offset, offset + 2)}", ` + + `where we would have expected a Markdown h1 ("# "), ` + + `encountered at position ${offset} in "${changelogPath}"`, + ); + offset += 2; + + // We don't care about the chapter content, skipt until it and a newline is encountered + offset = skipUntilChar(currentData, offset, ["\n", "\r"]); + offset = skipWhileChar(currentData, offset, ["\n", "\r"]); + } + + // Skip everything until a header is encountered + while (true) { + offset = skipUntilChar(currentData, offset, ["#"]); + if (currentData[offset] === undefined) { + break; + } + let rewindingOffset = offset - 1; + while (currentData[rewindingOffset] === " ") { + rewindingOffset--; + } + if ( + currentData[rewindingOffset] === "\n" || + currentData[rewindingOffset] === "\r" + ) { + break; + } + offset += 1; + } + + if (currentData.length <= offset + 3) { + continue; + } + + assert( + currentData.slice(offset, offset + 3) === "## ", + `Unexpected chars in "${changelogPath}" at position ${offset}: ` + + currentData.slice(offset, offset + 3), + ); + + /** Hypothetical h2 offset if we were to prepend it to the current h2. */ + let prependTopH2Offset = offset; + prependTopH2Offset--; + while (currentData[prependTopH2Offset] === " ") { + prependTopH2Offset--; + } + assert( + currentData[prependTopH2Offset] === "\n" || + currentData[prependTopH2Offset] === "\r", + `Unexpected char found when rewiding: ${currentData[prependTopH2Offset]}.`, + ); + + const topH2Offset = offset; + offset += 3; + offset = skipWhileChar(currentData, offset, [" "]); + if (currentData[offset] === undefined) { + continue; + } else if (currentData[offset] === "v") { + // A version is directly entered at that point + // Try to find the end of the SEMVER version entered + offset = skipUntilChar(currentData, offset, [" ", "\n", "\r"]); + if (currentData[offset] !== undefined) { + let defaultBehavior; + const currentTopVersionNumber = currentData.slice(offset + 1, offset); + const previousTopH2 = currentData.slice(topH2Offset, offset); + if (version !== currentTopVersionNumber) { + // That's not the one we want, add version on top in the preceding lines + console.log(""); + console.warn("Top h2 in CHANGELOG.md seems to be for another version."); + defaultBehavior = "prepend"; + } else if (currentData.slice(topH2Offset, offset) !== newTopH2) { + console.log(""); + console.warn( + "Top h2 in CHANGELOG.md was the same version but needs to be replaced.", + ); + defaultBehavior = "replace"; + } else { + console.log("Top h2 in CHANGELOG.md was already the version wanted."); + defaultBehavior = "replace"; + } + const behavior = await checkUpdateTypeWithUser( + previousTopH2, + newTopH2, + defaultBehavior, + ); + if (behavior === "abort") { + return; + } else if (behavior === "prepend") { + await writeChangelogFile({ + changelogPath, + readStream, + alreadyReadData: currentData, + newH2: newTopH2, + baseOffset: prependTopH2Offset, + replacingLength: 0, + }); + } else { + await writeChangelogFile({ + changelogPath, + readStream, + alreadyReadData: currentData, + newH2: newTopH2, + baseOffset: topH2Offset, + replacingLength: previousTopH2.length, + }); + } + return; + } + } else { + offset = skipUntilChar(currentData, offset, ["\n", "\r"]); + if (currentData[offset] !== undefined) { + const previousTopH2 = currentData.slice(topH2Offset, offset); + if (previousTopH2 === newTopH2) { + console.log(""); + console.log("Top h2 in CHANGELOG.md was already the one wanted."); + } else { + console.log(""); + console.warn("Replacing previous top h2 in CHANGELOG.md."); + } + const behavior = await checkUpdateTypeWithUser( + previousTopH2, + newTopH2, + "replace", + ); + if (behavior === "abort") { + return; + } else if (behavior === "prepend") { + await writeChangelogFile({ + changelogPath, + readStream, + alreadyReadData: currentData, + newH2: newTopH2, + baseOffset: prependTopH2Offset, + replacingLength: 0, + }); + } else { + await writeChangelogFile({ + changelogPath, + readStream, + alreadyReadData: currentData, + newH2: newTopH2, + baseOffset: topH2Offset, + replacingLength: previousTopH2.length, + }); + } + return; + } + } + } + throw new Error(`Did not find where to put new release in ${changelogPath}: `); +} + +/** + * Check with the user whether we should replace the previous Markdown h2 found + * inside the Changelog (`previousTopH2`) by our new h2 (`newTopH2`) or prepend + * the new one relative to the old one. + * + * @param {string} previousTopH2 - The previous first h2 encountered in the + * changelog. + * @param {string} newTopH2 - The new h2 that should be put in the changelog. + * @param {string} defaultBehaviour - The default behavior that should be taken. + * Can be set to one of: + * - `"replace"`: `newTopH2` will replace `previousTopH2` by default + * - `"prepend"`: `newTopH2` will come before `previousTopH2` by default + * @returns {Promise.} - Promise resolving with the actual behavior to + * take. Can be one of: + * - `"abort"`: Nothing should be done. + * - `"replace"`: `newTopH2` should replace `previousTopH2`. + * - `"prepend"`: `newTopH2` should come before `previousTopH2`. + */ +async function checkUpdateTypeWithUser(previousTopH2, newTopH2, defaultBehaviour) { + assert(["replace", "prepend"].includes(defaultBehaviour), "Invalid `defaultBehaviour`"); + if (defaultBehaviour === "replace" && previousTopH2 !== newTopH2) { + console.log("-"); + console.log(`replacing: "${previousTopH2}"`); + console.log(` with: "${newTopH2}"`); + console.log("-"); + console.log(""); + } + if (defaultBehaviour === "prepend") { + console.log("-"); + console.log(`previous top h2: "${previousTopH2}"`); + console.log(` new top h2: "${newTopH2}"`); + console.log("-"); + console.log(""); + console.log( + "We will assume that the previous one was for a previous version and " + + "prepend the new version on top.", + ); + } + const res = await readChar("Is this OK? [Y/n] "); + switch (res.toLowerCase()) { + case "": + case "y": + case "yes": + console.log("Writing CHANGELOG.md..."); + // XXX TODO + return defaultBehaviour; + case "n": + case "no": + console.log(""); + console.log("What do you want to do:"); + console.log(` a: abort CHANGELOG.md modification`); + console.log( + ` r: replace previous top h2 (${previousTopH2}) with the one wanted (${newTopH2})`, + ); + console.log( + ` p: Put new h2 (${newTopH2}) before previous top h2 (${previousTopH2})`, + ); + const res2 = await readChar("Your choice? [a/r/p] "); + switch (res2.toLowerCase()) { + case "": + case "a": + case "abort": + console.warn("aborting CHANGELOG.md modification."); + return "abort"; + case "r": + case "replace": + console.log("Writing CHANGELOG.md (replacing top h2)..."); + return "replace"; + case "p": + case "prepend": + case "put": + console.log("Writing CHANGELOG.md (prepending h2)..."); + return "prepend"; + default: + console.warn("Invalid input, aborting CHANGELOG.md mmodification."); + return "abort"; + } + default: + console.warn("Invalid input, aborting CHANGELOG.md modification."); + return "abort"; + } +} + +/** + * @param {string} args.changelogPath - Absolute path for the changelog file. + * @param {fs.ReadStream} args.readStream - The `ReadStream` used to read the + * changelog file. + * @param {string} args.alreadyReadData - The already read data at the beginning + * of the `readStream`. + * @param {string} args.newH2 - The new Markdown h2 line to put inside + * `changelogPath`. It might be followed by more generated lines listing updates + * performed in the corresponding release. + * @param {number} args.baseOffset - The offset in the string in `alreadyReadData + * at which `newH2` should be put inside `changelogPath`. + * @param {number} args.replacingLength - If set to a value superior to `0`, we + * will remove everything that was in `changelogPath` between the `baseOffset` + * and `baseOffset + replacingLength` offsets (in terms of JS Strings. + * You want to set this value if you want to replace text from the previous + * changelog. + * @returns {Promise} + */ +async function writeChangelogFile({ + changelogPath, + readStream, + alreadyReadData, + newH2, + baseOffset, + replacingLength, +}) { + const lines = await getChangelogLines(); + + // NOTE: We cannot directly seek in the write stream because our offset is + // relative to the string obtained from `readStream`, not the actual byte + // offset. + const writeStream = fs.createWriteStream(changelogPath); + + // Prepend same thing that base file + writeStream.write(alreadyReadData.slice(0, baseOffset)); + writeStream.write(newH2); + if (lines.length > 0) { + writeStream.write( + "\n" + + "\n" + + NOTICE_OPENER_CLOSER + + "\n\n" + + NOTICE_PREFIX + + "\n\n" + + lines.map((l) => "- " + l).join("\n") + + "\n\n" + + NOTICE_OPENER_CLOSER, + ); + } + if (replacingLength <= 0) { + writeStream.write("\n"); + } + const remainingData = alreadyReadData.slice( + baseOffset + replacingLength, + alreadyReadData.length, + ); + writeStream.write(remainingData); + + // TODO: There's surely a more readable and efficient way to do this. + // I don't know how to browse Node.JS documentation though + for await (const chunk of readStream) { + writeStream.write(chunk); + } + return new Promise((res, rej) => { + if (readStream.errored !== null) { + rej(readStream.errored); + return; + } + if (writeStream.errored !== null) { + rej(writeStream.errored); + return; + } + readStream.on("error", rej); + writeStream.on("error", rej); + writeStream.on("finish", () => { + writeStream.close(); + res(); + }); + }); +} + +/** + * @returns {Promise.>} + */ +function getChangelogLines() { + return new Promise((res, rej) => { + exec( + // NOTE: We use the NULL byte (%x00 in git syntax) to delimitate commits + // subject and body because it seems to be forbidden in there. + 'git log --merges --pretty=format:"%x00%s%x00%b" $(git log --pretty=format:"%h" CHANGELOG.md | head -1)..HEAD', + {}, + (error, stdout) => { + if (error !== null) { + rej(new Error("Unexpected error code when calling git-log: " + String(error))); + } + const splitted = stdout.split("\0").slice(1); + if (splitted.length === 0) { + res([]); + return; + } + assert( + splitted.length % 2 === 0, + "Unexpected git-log output, length = " + String(splitted.length), + ); + const linesToAdd = []; + for (let i = 0; i < splitted.length; i += 2) { + const mergeSubject = splitted[i]; + const mergeBody = splitted[i + 1]; + const match = mergeSubject.match(/\ #\d+ /); + if (match !== null) { + const issueNumber = match[0].trim(); + linesToAdd.push(mergeBody.trim() + ` [${issueNumber}]`); + } + } + res(linesToAdd); + }, + ); + }); +} + +/** + * Increment the offset into string into it points to one of the char provided + * in `chars`. + * @param {string} str + * @param {number} offset + * @param {Array.} chars + * @returns {number} + */ +function skipUntilChar(str, offset, chars) { + while (offset < str.length && !chars.includes(str[offset])) { + offset++; + } + return offset; +} + +/** + * Increment the offset into string into it points to another char than one + * provided in `chars`. + * @param {string} str + * @param {number} offset + * @param {Array.} chars + * @returns {number} + */ +function skipWhileChar(str, offset, chars) { + while (offset < str.length && chars.includes(str[offset])) { + offset++; + } + return offset; +} + +/** + * If the given `cond` is `false` throw an Error with `errorMsg` as a message. + * @param {boolean} cond + * @param {string} errorMsg + */ +function assert(cond, errorMsg) { + if (!cond) { + throw new Error(errorMsg); + } +} + +/** + * @param {string} query + * @returns {Promise.} + */ +async function readChar(query) { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(query, (res) => { + rl.close(); + resolve(res); + }); + }); +} + +/** + * Display through `console.log` an helping message relative to how to run this + * script. + */ +function displayHelp() { + /* eslint-disable no-console */ + console.log( + /* eslint-disable indent */ + `Usage: node update_changelog.mjs [options] +Options: +-h, --help Display this help +-d, --dev This is for a development release`, + /* eslint-enable indent */ + ); + /* eslint-enable no-console */ +}