diff --git a/.distignore b/.distignore index 48e1330..1c86fb2 100644 --- a/.distignore +++ b/.distignore @@ -4,7 +4,7 @@ /config /node_modules /tests -/scripts +/bin .distignore .editorconfig diff --git a/.gitattributes b/.gitattributes index edc4693..c2d7c0f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,7 +4,7 @@ /node_modules export-ignore -diff /tests export-ignore /vendor export-ignore -diff -/scripts export-ignore +/bin export-ignore /.distignore export-ignore /.editorconfig export-ignore /.gitattributes export-ignore diff --git a/bin/bump.sh b/bin/bump.sh new file mode 100644 index 0000000..38f9f7a --- /dev/null +++ b/bin/bump.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# +# Bump the plugin version across package, block metadata, PHP, and docs. +# +# Usage (no chmod required): +# bash bin/bump.sh patch +# bash bin/bump.sh minor +# bash bin/bump.sh major +# bash bin/bump.sh 2.2.0 +# npm run bump -- patch +# npm run bump -- 2.2.0 +# +# macOS sed (-i '') is assumed. +# + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Assets — add or remove paths here +# --------------------------------------------------------------------------- + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +# JSON files that expose a top-level "version" field +JSON_VERSION_FILES=( + ".plugin-data" + "package.json" + "src/faq/block.json" + "src/faq-answer/block.json" + "src/faq-item/block.json" + "src/faq-question/block.json" +) + +PACKAGE_LOCK_FILE="package-lock.json" +PHP_FILE="blockparty-faq.php" +README_TXT="readme.txt" +CHANGELOG="CHANGELOG.md" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +UPDATED_FILES=() + +log_updated() { + UPDATED_FILES+=( "$1" ) +} + +require_file() { + local file="$1" + if [[ ! -f "${ROOT_DIR}/${file}" ]]; then + echo "Error: Missing file ${file}" >&2 + exit 1 + fi +} + +replace_json_version() { + local file="$1" + local path="${ROOT_DIR}/${file}" + require_file "${file}" + sed -i '' "s/\"version\": \"[0-9]*\.[0-9]*\.[0-9]*\"/\"version\": \"${VERSION}\"/" "${path}" + log_updated "${file}" +} + +usage() { + local code="${1:-0}" + echo "Usage: bash bin/bump.sh " + echo "" + echo "Examples:" + echo " bash bin/bump.sh patch" + echo " bash bin/bump.sh minor" + echo " bash bin/bump.sh 2.2.0" + echo " npm run bump -- patch" + exit "${code}" +} + +resolve_version() { + local current="$1" + local input="$2" + + if [[ "${input}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "${input}" + return + fi + + local major minor patch + IFS='.' read -r major minor patch <<< "${current}" + + case "${input}" in + major) + echo "$((major + 1)).0.0" + ;; + minor) + echo "${major}.$((minor + 1)).0" + ;; + patch) + echo "${major}.${minor}.$((patch + 1))" + ;; + *) + echo "Error: Unknown bump argument \"${input}\". Use patch, minor, major, or an explicit x.y.z version." >&2 + exit 1 + ;; + esac +} + +escape_sed() { + printf '%s\n' "$1" | sed -e 's/[.[\*^$\/]/\\&/g' +} + +# --------------------------------------------------------------------------- +# Args +# --------------------------------------------------------------------------- + +if [[ -z "${1:-}" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage "$([[ -n "${1:-}" ]] && echo 0 || echo 1)" +fi + +INPUT="$1" + +require_file ".plugin-data" +CURRENT="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([0-9]*\.[0-9]*\.[0-9]*\)".*/\1/p' "${ROOT_DIR}/.plugin-data" | head -n 1)" + +if [[ -z "${CURRENT}" ]]; then + echo "Error: Could not read current version from .plugin-data" >&2 + exit 1 +fi + +if ! [[ "${CURRENT}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid current version \"${CURRENT}\" in .plugin-data" >&2 + exit 1 +fi + +VERSION="$(resolve_version "${CURRENT}" "${INPUT}")" + +if [[ "${VERSION}" == "${CURRENT}" ]]; then + echo "Error: Version is already ${CURRENT}" >&2 + exit 1 +fi + +echo "Bumping version: ${CURRENT} → ${VERSION}" +TODAY="$(date +%Y-%m-%d)" + +# --------------------------------------------------------------------------- +# JSON version fields +# --------------------------------------------------------------------------- + +for file in "${JSON_VERSION_FILES[@]}"; do + replace_json_version "${file}" +done + +# --------------------------------------------------------------------------- +# package-lock.json (root + packages[""] only) +# --------------------------------------------------------------------------- + +if [[ -f "${ROOT_DIR}/${PACKAGE_LOCK_FILE}" ]]; then + # Root "version" (near top of file) + sed -i '' '1,5s/"version": "[0-9]*\.[0-9]*\.[0-9]*"/"version": "'"${VERSION}"'"/' "${ROOT_DIR}/${PACKAGE_LOCK_FILE}" + # packages[""].version + sed -i '' '6,12s/"version": "[0-9]*\.[0-9]*\.[0-9]*"/"version": "'"${VERSION}"'"/' "${ROOT_DIR}/${PACKAGE_LOCK_FILE}" + log_updated "${PACKAGE_LOCK_FILE}" +fi + +# --------------------------------------------------------------------------- +# Main plugin PHP +# --------------------------------------------------------------------------- + +require_file "${PHP_FILE}" +sed -i '' "s/^\( \* Version:[[:space:]]*\)$(escape_sed "${CURRENT}")/\1${VERSION}/" "${ROOT_DIR}/${PHP_FILE}" +sed -i '' "s/define( 'BLOCKPARTY_FAQ_VERSION', '$(escape_sed "${CURRENT}")' )/define( 'BLOCKPARTY_FAQ_VERSION', '${VERSION}' )/" "${ROOT_DIR}/${PHP_FILE}" +log_updated "${PHP_FILE}" + +# --------------------------------------------------------------------------- +# readme.txt — Stable tag + changelog + Upgrade Notice stubs +# --------------------------------------------------------------------------- + +require_file "${README_TXT}" +sed -i '' "s/^\(Stable tag:[[:space:]]*\)$(escape_sed "${CURRENT}")/\1${VERSION}/" "${ROOT_DIR}/${README_TXT}" + +if grep -q "^= ${VERSION} - " "${ROOT_DIR}/${README_TXT}"; then + echo "Error: readme.txt already contains a changelog entry for ${VERSION}" >&2 + exit 1 +fi + +awk -v ver="${VERSION}" -v today="${TODAY}" -v current="${CURRENT}" ' + { + if ( $0 ~ ("^= " current " - ") ) { + print "= " ver " - " today " =" + print "* TBD" + print "" + } + print + } +' "${ROOT_DIR}/${README_TXT}" > "${ROOT_DIR}/${README_TXT}.tmp" +mv "${ROOT_DIR}/${README_TXT}.tmp" "${ROOT_DIR}/${README_TXT}" + +if grep -q "^= ${VERSION} =$" "${ROOT_DIR}/${README_TXT}"; then + echo "Error: readme.txt already contains an Upgrade Notice for ${VERSION}" >&2 + exit 1 +fi + +awk -v ver="${VERSION}" ' + { + print + if ( $0 == "== Upgrade Notice ==" ) { + getline + print "" + print "= " ver " =" + print "TBD" + print "" + if ( $0 != "" ) { + print + } + } + } +' "${ROOT_DIR}/${README_TXT}" > "${ROOT_DIR}/${README_TXT}.tmp" +mv "${ROOT_DIR}/${README_TXT}.tmp" "${ROOT_DIR}/${README_TXT}" +log_updated "${README_TXT}" + +# --------------------------------------------------------------------------- +# CHANGELOG.md — Keep a Changelog entry +# --------------------------------------------------------------------------- + +require_file "${CHANGELOG}" + +if grep -q "^## \[${VERSION}\]" "${ROOT_DIR}/${CHANGELOG}"; then + echo "Error: CHANGELOG.md already contains an entry for ${VERSION}" >&2 + exit 1 +fi + +awk -v ver="${VERSION}" -v today="${TODAY}" ' + { + print + if ( !inserted && index( $0, "Semantic Versioning" ) ) { + getline + print "" + print "## [" ver "] - " today + print "" + print "### Changed" + print "" + print "- TBD" + print "" + inserted = 1 + # Marker is followed by a blank line; skip the consumed blank. + next + } + } +' "${ROOT_DIR}/${CHANGELOG}" > "${ROOT_DIR}/${CHANGELOG}.tmp" +mv "${ROOT_DIR}/${CHANGELOG}.tmp" "${ROOT_DIR}/${CHANGELOG}" +log_updated "${CHANGELOG}" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +echo "" +echo "Bumped version ${CURRENT} → ${VERSION}" +echo "Updated files:" +for file in "${UPDATED_FILES[@]}"; do + echo " - ${file}" +done +echo "" +echo "Fill in the TBD changelog entries before releasing." diff --git a/package.json b/package.json index dbd7c47..5ba1142 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "make-json": "wp i18n make-json languages/blockparty-faq-fr_FR.po languages/ --no-purge", "env:start": "wp-env start", "env:stop": "wp-env stop", - "bump": "node scripts/bump-version.js" + "bump": "bash bin/bump.sh" }, "dependencies": { "@beapi/be-a11y": "^1.7.2", diff --git a/scripts/bump-version.js b/scripts/bump-version.js deleted file mode 100755 index 43c641b..0000000 --- a/scripts/bump-version.js +++ /dev/null @@ -1,392 +0,0 @@ -#!/usr/bin/env node -/** - * Bump the plugin version across package, block metadata, PHP, and docs. - * - * Usage: - * npm run bump -- patch - * npm run bump -- minor - * npm run bump -- major - * npm run bump -- 2.2.0 - * - * @package blockparty-faq - */ - -const fs = require( 'fs' ); -const path = require( 'path' ); - -const ROOT = path.resolve( __dirname, '..' ); - -const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; - -/** - * Resolve the absolute path of a project-relative file. - * - * @param {string} relativePath Relative path from the project root. - * @return {string} Absolute file path. - */ -const resolvePath = ( relativePath ) => path.join( ROOT, relativePath ); - -/** - * Read a UTF-8 file from the project root. - * - * @param {string} relativePath Relative path from the project root. - * @return {string} File contents. - */ -const readFile = ( relativePath ) => - fs.readFileSync( resolvePath( relativePath ), 'utf8' ); - -/** - * Write a UTF-8 file to the project root. - * - * @param {string} relativePath Relative path from the project root. - * @param {string} contents File contents. - * @return {void} - */ -const writeFile = ( relativePath, contents ) => { - fs.writeFileSync( resolvePath( relativePath ), contents, 'utf8' ); -}; - -/** - * Parse a JSON file from the project root. - * - * @param {string} relativePath Relative path from the project root. - * @return {Object} Parsed JSON object. - */ -const readJson = ( relativePath ) => JSON.parse( readFile( relativePath ) ); - -/** - * Write a JSON file with tab indentation (project convention). - * - * @param {string} relativePath Relative path from the project root. - * @param {Object} data Data to serialize. - * @return {void} - */ -const writeJson = ( relativePath, data ) => { - writeFile( relativePath, `${ JSON.stringify( data, null, '\t' ) }\n` ); -}; - -/** - * Print usage help and exit. - * - * @param {number} exitCode Process exit code. - * @return {void} - */ -const printUsage = ( exitCode = 1 ) => { - console.error( ` -Usage: - npm run bump -- - -Examples: - npm run bump -- patch - npm run bump -- minor - npm run bump -- 2.2.0 -`.trim() ); - process.exit( exitCode ); -}; - -/** - * Compute the next semantic version. - * - * @param {string} current Current version (x.y.z). - * @param {string} bump Bump type or explicit version. - * @return {string} Next version. - */ -const getNextVersion = ( current, bump ) => { - if ( SEMVER_RE.test( bump ) ) { - return bump; - } - - const match = current.match( SEMVER_RE ); - if ( ! match ) { - throw new Error( `Invalid current version: ${ current }` ); - } - - let major = Number( match[ 1 ] ); - let minor = Number( match[ 2 ] ); - let patch = Number( match[ 3 ] ); - - switch ( bump ) { - case 'major': - major += 1; - minor = 0; - patch = 0; - break; - case 'minor': - minor += 1; - patch = 0; - break; - case 'patch': - patch += 1; - break; - default: - throw new Error( - `Unknown bump type "${ bump }". Use patch, minor, major, or x.y.z.` - ); - } - - return `${ major }.${ minor }.${ patch }`; -}; - -/** - * Format today's date as YYYY-MM-DD (local timezone). - * - * @return {string} ISO date string without time. - */ -const today = () => { - const now = new Date(); - const year = now.getFullYear(); - const month = String( now.getMonth() + 1 ).padStart( 2, '0' ); - const day = String( now.getDate() ).padStart( 2, '0' ); - return `${ year }-${ month }-${ day }`; -}; - -/** - * Replace the first occurrence of a pattern or throw. - * - * @param {string} contents File contents. - * @param {RegExp|string} search Search pattern. - * @param {string} replace Replacement. - * @param {string} label Human-readable label for errors. - * @return {string} Updated contents. - */ -const replaceOnce = ( contents, search, replace, label ) => { - if ( typeof search === 'string' ) { - if ( ! contents.includes( search ) ) { - throw new Error( `Could not find ${ label }.` ); - } - return contents.replace( search, replace ); - } - - if ( ! search.test( contents ) ) { - throw new Error( `Could not find ${ label }.` ); - } - - return contents.replace( search, replace ); -}; - -/** - * Update version fields in a JSON file that has a top-level "version" key. - * - * @param {string} relativePath Relative path from the project root. - * @param {string} version New version. - * @return {void} - */ -const updateJsonVersion = ( relativePath, version ) => { - const data = readJson( relativePath ); - if ( typeof data.version !== 'string' ) { - throw new Error( `Missing "version" in ${ relativePath }.` ); - } - data.version = version; - writeJson( relativePath, data ); -}; - -/** - * Update package-lock.json root package version fields. - * - * @param {string} version New version. - * @return {void} - */ -const updatePackageLock = ( version ) => { - const lockPath = 'package-lock.json'; - if ( ! fs.existsSync( resolvePath( lockPath ) ) ) { - return; - } - - const lock = readJson( lockPath ); - lock.version = version; - if ( lock.packages && lock.packages[ '' ] ) { - lock.packages[ '' ].version = version; - } - writeJson( lockPath, lock ); -}; - -/** - * Update version strings in the main plugin PHP file. - * - * @param {string} currentVersion Current version. - * @param {string} nextVersion New version. - * @return {void} - */ -const updatePluginPhp = ( currentVersion, nextVersion ) => { - const relativePath = 'blockparty-faq.php'; - let contents = readFile( relativePath ); - - contents = replaceOnce( - contents, - new RegExp( - `( \\* Version:\\s+)${ escapeRegExp( currentVersion ) }` - ), - `$1${ nextVersion }`, - `plugin header Version in ${ relativePath }` - ); - - contents = replaceOnce( - contents, - `define( 'BLOCKPARTY_FAQ_VERSION', '${ currentVersion }' );`, - `define( 'BLOCKPARTY_FAQ_VERSION', '${ nextVersion }' );`, - `BLOCKPARTY_FAQ_VERSION in ${ relativePath }` - ); - - writeFile( relativePath, contents ); -}; - -/** - * Escape a string for safe use inside a RegExp. - * - * @param {string} value Raw string. - * @return {string} Escaped string. - */ -const escapeRegExp = ( value ) => - value.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); - -/** - * Update Stable tag and changelog stubs in readme.txt. - * - * @param {string} currentVersion Current version. - * @param {string} nextVersion New version. - * @param {string} date Release date (YYYY-MM-DD). - * @return {void} - */ -const updateReadmeTxt = ( currentVersion, nextVersion, date ) => { - const relativePath = 'readme.txt'; - let contents = readFile( relativePath ); - - contents = replaceOnce( - contents, - new RegExp( - `(Stable tag:\\s+)${ escapeRegExp( currentVersion ) }` - ), - `$1${ nextVersion }`, - `Stable tag in ${ relativePath }` - ); - - const changelogStub = `= ${ nextVersion } - ${ date } =\n* TBD\n\n`; - contents = replaceOnce( - contents, - `= ${ currentVersion }`, - `${ changelogStub }= ${ currentVersion }`, - `changelog section in ${ relativePath }` - ); - - const upgradeStub = `= ${ nextVersion } =\nTBD\n\n`; - contents = replaceOnce( - contents, - '== Upgrade Notice ==\n\n', - `== Upgrade Notice ==\n\n${ upgradeStub }`, - `Upgrade Notice in ${ relativePath }` - ); - - writeFile( relativePath, contents ); -}; - -/** - * Prepend a Keep a Changelog stub in CHANGELOG.md. - * - * @param {string} nextVersion New version. - * @param {string} date Release date (YYYY-MM-DD). - * @return {void} - */ -const updateChangelogMd = ( nextVersion, date ) => { - const relativePath = 'CHANGELOG.md'; - let contents = readFile( relativePath ); - - if ( contents.includes( `## [${ nextVersion }]` ) ) { - throw new Error( - `CHANGELOG.md already contains an entry for ${ nextVersion }.` - ); - } - - const stub = `## [${ nextVersion }] - ${ date } - -### Changed - -- TBD - -`; - - contents = replaceOnce( - contents, - 'and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n', - `and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n${ stub }`, - `Semantic Versioning intro in ${ relativePath }` - ); - - writeFile( relativePath, contents ); -}; - -/** - * Main entry point. - * - * @return {void} - */ -const main = () => { - const bumpArg = process.argv[ 2 ]; - - if ( ! bumpArg || bumpArg === '-h' || bumpArg === '--help' ) { - printUsage( bumpArg ? 0 : 1 ); - } - - const pluginData = readJson( '.plugin-data' ); - const packageJson = readJson( 'package.json' ); - - const currentVersion = pluginData.version || packageJson.version; - if ( ! SEMVER_RE.test( currentVersion ) ) { - throw new Error( `Invalid current version: ${ currentVersion }` ); - } - - const nextVersion = getNextVersion( currentVersion, bumpArg ); - if ( nextVersion === currentVersion ) { - throw new Error( - `Next version ${ nextVersion } is identical to the current version.` - ); - } - - const date = today(); - const updated = []; - - updateJsonVersion( '.plugin-data', nextVersion ); - updated.push( '.plugin-data' ); - - updateJsonVersion( 'package.json', nextVersion ); - updated.push( 'package.json' ); - - updatePackageLock( nextVersion ); - if ( fs.existsSync( resolvePath( 'package-lock.json' ) ) ) { - updated.push( 'package-lock.json' ); - } - - const blockJsonFiles = [ - 'src/faq/block.json', - 'src/faq-answer/block.json', - 'src/faq-item/block.json', - 'src/faq-question/block.json', - ]; - - blockJsonFiles.forEach( ( file ) => { - updateJsonVersion( file, nextVersion ); - updated.push( file ); - } ); - - updatePluginPhp( currentVersion, nextVersion ); - updated.push( 'blockparty-faq.php' ); - - updateReadmeTxt( currentVersion, nextVersion, date ); - updated.push( 'readme.txt' ); - - updateChangelogMd( nextVersion, date ); - updated.push( 'CHANGELOG.md' ); - - console.log( `Bumped version ${ currentVersion } → ${ nextVersion }` ); - console.log( 'Updated files:' ); - updated.forEach( ( file ) => console.log( ` - ${ file }` ) ); - console.log( - '\nFill in the TBD changelog entries before releasing.' - ); -}; - -try { - main(); -} catch ( error ) { - console.error( `Error: ${ error.message }` ); - process.exit( 1 ); -}