From 5b2442bb450b67662ee4920037fd78dfe1e374f4 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 13 Jul 2026 10:02:51 +0200 Subject: [PATCH] build: initialize a release script Automates following functionality: - prepare and read changelog - derive required version bump - commit changes Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Maksim Sukharev --- README.md | 25 ++- build/cli-utils.mjs | 145 ++++++++++++++++ build/release.mjs | 415 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 4 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 build/cli-utils.mjs create mode 100644 build/release.mjs diff --git a/README.md b/README.md index 7ede0993ca..398a114c87 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,24 @@ npm run l10n:extract ## πŸ“€ Releasing a new version +`npm run release` automates everything up to the release commit: it pulls the +base branch, bumps the version (detecting patch/minor/major from the changes), +creates the `chore/release-` branch, and updates and commits +`CHANGELOG.md`. Pushing and opening the PR stay manual β€” the script prints the +commands to run. + +```sh +npm run release # detect the bump on the current branch +npm run release -- minor --base main # override the bump and/or base branch +``` + +Requires `git`, `gh` (run `gh auth login` first) and `npm`. Each step is a +confirmation navigated with `↑`/`↓` + `Enter` (no typing); pass `--yes` to skip +them, or `--help` for all options. + +
+Doing these steps manually + - Pull the latest changes from `main` or `stableX` - Checkout a new branch with the tag name (e.g `v4.0.1`): `git checkout -b v` - Run `npm version patch --no-git-tag-version` (`npm version minor --no-git-tag-version` if minor). @@ -172,7 +190,12 @@ npm run l10n:extract Which this as the replacement: `[\#$4]($2) \([$1]($3$1)\)` 2. use the the version as tag AND title (e.g `v4.0.1`) 3. add the changelog content as description (https://github.com/nextcloud-libraries/nextcloud-vue/releases) -- Commit, push and create PR +- Stage and commit the changes: + `git add package.json package-lock.json CHANGELOG.md && git commit --signoff --message "chore(release): v"` + +
+ +- Push and create PR - Get your PR reviewed and merged - Create a milestone with the follow-up version at https://github.com/nextcloud-libraries/nextcloud-vue/milestones - Move all open tickets and PRs to the follow-up diff --git a/build/cli-utils.mjs b/build/cli-utils.mjs new file mode 100644 index 0000000000..f6062d4cab --- /dev/null +++ b/build/cli-utils.mjs @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/* eslint-disable no-console */ + +/** + * Generic command-line helpers shared by the build scripts: coloured terminal + * output, fail-fast subprocess runners and keyboard-driven prompts. Nothing + * here is specific to the release process. + */ + +import { spawnSync } from 'node:child_process' +import process, { stdin, stdout } from 'node:process' +import { emitKeypressEvents } from 'node:readline' + +// --- Terminal helpers ------------------------------------------------------ +export const c = { + info: (m) => console.info(`\x1b[1;34m➜\x1b[0m ${m}`), + ok: (m) => console.info(`\x1b[1;32mβœ”\x1b[0m ${m}`), + warn: (m) => console.warn(`\x1b[1;33m!\x1b[0m ${m}`), + err: (m) => console.error(`\x1b[1;31mβœ–\x1b[0m ${m}`), +} + +/** + * Run a command, inheriting stdio, and exit the process on failure. + * + * @param {string} cmd command to run + * @param {string[]} args arguments + * @param {object} [options] extra spawn options; `capture` pipes stdout back + * @return {string} captured stdout (trimmed) when `capture` is set, else '' + */ +export function run(cmd, args, options = {}) { + const { capture = false, ...rest } = options + const result = spawnSync(cmd, args, { + encoding: 'utf-8', + stdio: capture ? ['inherit', 'pipe', 'inherit'] : 'inherit', + ...rest, + }) + if (result.status !== 0) { + c.err(`Command failed: ${cmd} ${args.join(' ')}`) + process.exit(result.status ?? 1) + } + return capture ? (result.stdout ?? '').trim() : '' +} + +/** + * Run a command only to read its output, returning null on failure. + * + * @param {string} cmd command to run + * @param {string[]} args arguments + * @param {object} [options] extra spawn options + * @return {string|null} trimmed stdout, or null if the command failed + */ +export function tryRead(cmd, args, options = {}) { + const result = spawnSync(cmd, args, { encoding: 'utf-8', ...options }) + return result.status === 0 ? (result.stdout ?? '').trim() : null +} + +/** + * Prompt with an arrow-key list (↑/↓ or k/j, Enter to pick, Esc/Ctrl+C to + * abort). Returns the default without prompting when autoYes is set or stdin is + * not a TTY, so callers still run unattended. + * + * @param {string} question the prompt to display + * @param {Array<{label: string, value: boolean|string}>} choices the selectable options + * @param {object} [options] prompt options + * @param {boolean|string} [options.defaultValue] value highlighted first / used non-interactively + * @param {boolean} [options.autoYes] skip the prompt and return the default + * @return {Promise} the chosen value + */ +export function select(question, choices, { defaultValue, autoYes = false } = {}) { + let index = choices.findIndex((choice) => choice.value === defaultValue) + if (index === -1) { + index = 0 + } + + if (autoYes || !stdin.isTTY) { + const reason = autoYes ? 'auto-confirm' : 'non-interactive shell' + c.info(`${question} β€” ${choices[index].label} (auto-selected, ${reason})`) + return Promise.resolve(choices[index].value) + } + + return new Promise((resolve) => { + const render = (first) => { + if (!first) { + stdout.write(`\x1b[${choices.length}A`) // move back up to redraw the list + } + for (const [i, choice] of choices.entries()) { + const active = i === index + const pointer = active ? '\x1b[1;36m❯\x1b[0m' : ' ' + const label = active ? `\x1b[1;36m${choice.label}\x1b[0m` : choice.label + stdout.write(`\x1b[2K${pointer} ${label}\n`) + } + } + + const cleanup = () => { + stdin.removeAllListeners('keypress') + stdin.setRawMode(false) + stdin.pause() + } + + const onKeypress = (_str, key) => { + if (key.name === 'up' || key.name === 'k') { + index = (index - 1 + choices.length) % choices.length + render(false) + } else if (key.name === 'down' || key.name === 'j') { + index = (index + 1) % choices.length + render(false) + } else if (key.name === 'return' || key.name === 'enter') { + cleanup() + // Replace the header + list with a one-line summary. + stdout.write(`\x1b[${choices.length + 1}A\x1b[0J`) + stdout.write(`\x1b[1;32mβœ”\x1b[0m ${question} \x1b[1;36m${choices[index].label}\x1b[0m\n`) + resolve(choices[index].value) + } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + cleanup() + c.err('Aborted.') + process.exit(130) + } + } + + stdout.write(`\x1b[1;36m?\x1b[0m ${question} \x1b[2m(↑/↓ to move, Enter to select)\x1b[0m\n`) + emitKeypressEvents(stdin) + stdin.setRawMode(true) + stdin.resume() + render(true) + stdin.on('keypress', onKeypress) + }) +} + +/** + * Ask a yes/no question via an arrow-key select. + * + * @param {string} question the prompt to display + * @param {object} [options] prompt options + * @param {boolean} [options.autoYes] skip the prompt and confirm automatically + * @return {Promise} true when the user confirms + */ +export function confirm(question, { autoYes = false } = {}) { + return select(question, [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], { defaultValue: true, autoYes }) +} diff --git a/build/release.mjs b/build/release.mjs new file mode 100644 index 0000000000..209232cce3 --- /dev/null +++ b/build/release.mjs @@ -0,0 +1,415 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/* eslint-disable no-console */ + +/** + * Automates the "πŸ“€ Releasing a new version" steps from README.md, up to and + * including the release commit (pushing and opening the PR stay manual). + * + * Prompts are arrow-key selects (↑/↓ + Enter, no typing). Before each step + * (unless --yes is passed) it will: + * 1. Check out and pull the latest changes of the base branch (main or stableX) + * 2. Show GitHub's categorised changes since the last tag, then bump the + * version (`npm version --no-git-tag-version`). The bump type is + * inferred from those changes (πŸ’₯ Breaking β†’ major, πŸš€ Enhancements β†’ + * minor, otherwise patch) and offered as the default; pass an explicit + * [patch|minor|major] argument to override it. + * 3. Create the release branch `chore/release-` + * 4. Prepend those (formatted) release notes to CHANGELOG.md, then stage and + * commit the release (`chore(release): `, signed off) + * + * Requirements: git, gh (authenticated), npm. + * + * Usage: + * npm run release -- [patch|minor|major] [options] + * node build/release.mjs [patch|minor|major] [options] + * + * The [patch|minor|major] argument is optional β€” when omitted the bump is + * detected from the categorised changes since the last tag. + * + * Options: + * -b, --base Branch to base the release on (e.g. main, stable8). + * Defaults to the currently checked-out branch. + * -y, --yes Auto-confirm every step (no prompts); uses the + * detected bump unless one is given explicitly + * -h, --help Show this help + */ + +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import process from 'node:process' +import { c, confirm as confirmPrompt, tryRead as readCommand, run as runCommand, select } from './cli-utils.mjs' +import { formatChangelog } from './format-changelog.mjs' + +const REPO_SLUG = 'nextcloud-libraries/nextcloud-vue' +const REPO_URL = `https://github.com/${REPO_SLUG}` +const ROOT = join(import.meta.dirname, '..') + +// Bind the generic runners to the repository root, so every git/gh/npm call +// operates on this repository regardless of the caller's working directory. +const run = (cmd, args, options = {}) => runCommand(cmd, args, { cwd: ROOT, ...options }) +const tryRead = (cmd, args) => readCommand(cmd, args, { cwd: ROOT }) + +/** + * Fetch GitHub's categorised release notes for the range `previousTag..target`. + * The tag need not exist yet β€” the notes only depend on the range's commits. + * + * @param {string} tagName tag label for the notes (may be a placeholder) + * @param {string} previousTag the previous release tag + * @param {string} target branch or commit the release is built from + * @return {string} the generated markdown body + */ +function generateNotes(tagName, previousTag, target) { + return run( + 'gh', + [ + 'api', + `repos/${REPO_SLUG}/releases/generate-notes`, + '-f', + `tag_name=${tagName}`, + '-f', + `target_commitish=${target}`, + '-f', + `previous_tag_name=${previousTag}`, + '--jq', + '.body', + ], + { capture: true }, + ) +} + +/** + * Strip GitHub boilerplate (leading HTML comment, trailing "Full Changelog" + * line and trailing blank lines) so the notes match the CHANGELOG.md style. + * + * @param {string} rawBody the raw generated notes body + * @return {string} the cleaned markdown + */ +function cleanNotes(rawBody) { + return rawBody + .split('\n') + .filter((line) => !line.startsWith('