diff --git a/Makefile b/Makefile index 81fa104ad47..fbc39248aab 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,18 @@ dev-setup: clean-dev npm-init build-dev production-setup: clean-dev npm-init build-production +validate-release: + node ./scripts/validate-release.mjs $(BRANCHES) + +prepare-changelog: + node ./scripts/prepare-changelog.mjs $(BRANCHES) + +bump-version: + node ./scripts/bump-version.mjs $(PR) + +update-milestones: + node ./scripts/update-milestones.mjs $(BRANCH) + release: appstore create-tag build-dev: composer-install-dev build-js diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs new file mode 100644 index 00000000000..d385373b749 --- /dev/null +++ b/scripts/bump-version.mjs @@ -0,0 +1,255 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Check out a backport changelog PR (or an existing branch), bump the + * version in appinfo/info.xml and package.json, then commit the result. + * + * Requires: git, npm. + * + * Usage: + * node scripts/bump-version.mjs + * + * Arguments: + * GitHub PR number of the backported changelog PR + * An existing branch to bump directly. Checked out + * locally if present, otherwise fetched and tracked + * from origin. Pulled either way. + * + * Options: + * -h, --help Show this help + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import process from 'node:process' +import { branchExists, ghFetch, parseArgs, preflight, print, requireCleanWorktree, run } from './cli-utils.mjs' +import { getCurrentBranch, incrementVersion, readLocalInfoVersion } from './release-utils.mjs' + +const REPO = 'nextcloud/spreed' + +/** Print usage information and exit. */ +function usage() { + print.log(`Usage: node scripts/bump-version.mjs + +Check out the given changelog backport PR (or an existing branch), bump the +version in appinfo/info.xml and package.json, then commit the result. + +Arguments: + GitHub PR number of the backported changelog PR + An existing branch to bump directly. Checked out + locally if present, otherwise fetched and tracked + from origin. Pulled either way. + +Options: + -h, --help Show this help message + +Examples: + node scripts/bump-version.mjs 18500 + node scripts/bump-version.mjs stable33 +`) + process.exit(0) +} + +/** + * Parse CLI arguments. + * + * @return {{type: 'pr'|'branch', value: string}} the checkout target + */ +function parseArguments() { + let target = null + + parseArgs(process.argv.slice(2), { + usage, + onPositional: (arg) => { + target = /^\d+$/.test(arg) ? { type: 'pr', value: arg } : { type: 'branch', value: arg } + }, + }) + + if (!target) { + print.err('A changelog PR number or branch name is required') + usage() + } + + return target +} + +/** Check required tools are available; exit if not. */ +function checkPreflight() { + if (!preflight(['git', 'npm'])) { + process.exit(1) + } +} + +/** + * Check out the changelog PR branch, via GitHub's refs/pull//head mirror + * (works for fork PRs too, no `gh` needed). + * + * @param {string} prNumber the changelog PR number + * @return {Promise} the checked-out branch name + */ +async function checkoutPr(prNumber) { + print.section(`Checking out PR #${prNumber}`) + + const pr = await ghFetch(`/repos/${REPO}/pulls/${prNumber}`) + const branchName = pr.head.ref + + run('git', ['fetch', 'origin', `pull/${prNumber}/head`]) + run('git', ['checkout', '-B', branchName, 'FETCH_HEAD']) + + const currentBranch = getCurrentBranch() + print.ok(`Now on branch: ${currentBranch}`) + + return currentBranch +} + +/** + * Check out the given branch: switch to it if it exists locally, otherwise + * fetch and track it from origin. Pulls the latest changes either way. + * + * @param {string} branchName the branch to check out + * @return {string} the checked-out branch name + */ +function checkoutBranch(branchName) { + print.section(`Checking out branch '${branchName}'`) + + const onOrigin = branchExists(`origin/${branchName}`) + + if (branchExists(branchName)) { + run('git', ['checkout', branchName]) + } else if (onOrigin) { + run('git', ['fetch', 'origin', branchName]) + run('git', ['checkout', '-b', branchName, `origin/${branchName}`]) + } else { + print.err(`Branch '${branchName}' not found locally or on origin`) + process.exit(1) + } + + // Only pull when there's a remote counterpart to pull from. + if (onOrigin) { + run('git', ['pull', '--ff-only', 'origin', branchName]) + } + + const currentBranch = getCurrentBranch() + print.ok(`Now on branch: ${currentBranch}`) + + return currentBranch +} + +/** + * Read the current version from appinfo/info.xml and compute the next one. + * + * @param {string} currentBranch the branch being released, for the report header + * @return {{currentVersion: string, nextVersion: string}} the current and next version + */ +function readAndIncrementVersion(currentBranch) { + if (!existsSync('appinfo/info.xml')) { + print.err('appinfo/info.xml not found') + process.exit(1) + } + + const currentVersion = readLocalInfoVersion() + if (!currentVersion) { + print.err('Could not read version from appinfo/info.xml') + process.exit(1) + } + + const nextVersion = incrementVersion(currentVersion) + if (nextVersion === null) { + print.err(`Invalid semantic version in appinfo/info.xml: ${currentVersion}`) + process.exit(1) + } + + print.header([` Bump version: v${currentVersion} → v${nextVersion}`, ` Branch: ${currentBranch}`]) + + return { currentVersion, nextVersion } +} + +/** + * Bump the version in appinfo/info.xml and verify the edit landed. + * + * @param {string} currentVersion the version currently in the file + * @param {string} nextVersion the version to write + */ +function bumpInfoXml(currentVersion, nextVersion) { + print.section('Bumping appinfo/info.xml') + + const xml = readFileSync('appinfo/info.xml', 'utf-8') + writeFileSync( + 'appinfo/info.xml', + xml.replace(`${currentVersion}`, `${nextVersion}`), + ) + + const verify = readLocalInfoVersion() + if (verify !== nextVersion) { + print.err(`Version mismatch after edit — expected ${nextVersion}, got ${verify}`) + process.exit(1) + } + print.ok(`appinfo/info.xml → ${nextVersion}`) +} + +/** + * Bump the version in package.json via `npm version` and verify its output. + * + * @param {string} nextVersion the version to bump to + */ +function bumpPackageJson(nextVersion) { + print.section('Bumping package.json') + + // npm version prints the new version prefixed with 'v', e.g. v25.0.1 + const npmOutput = run('npm', ['version', '--no-git-tag-version', nextVersion], { capture: true }).replace(/^v/, '') + + if (npmOutput !== nextVersion) { + print.err(`npm version returned '${npmOutput}', expected '${nextVersion}'`) + process.exit(1) + } + print.ok(`package.json → ${nextVersion}`) +} + +/** + * Commit the version bump. + * + * @param {string} nextVersion the version being released, for the commit message + */ +function commitChanges(nextVersion) { + print.section('Committing') + + run('git', ['add', 'appinfo/info.xml', 'package.json', 'package-lock.json']) + run('git', ['commit', '-s', '-m', `chore(release): Prepare release v${nextVersion}`]) // -s for DCO + + print.ok(`Committed: chore(release): Prepare release v${nextVersion}`) +} + +/** + * Print the final push instructions. Push itself stays manual. + * + * @param {string} currentBranch the branch to push + */ +function printDone(currentBranch) { + print.header(' Done — push when ready:') + print.log() + print.log(` git push origin ${currentBranch}`) + print.log() +} + +/** Run the version bump. */ +async function main() { + const target = parseArguments() + checkPreflight() + requireCleanWorktree() + + const currentBranch = target.type === 'pr' ? await checkoutPr(target.value) : checkoutBranch(target.value) + const { currentVersion, nextVersion } = readAndIncrementVersion(currentBranch) + + bumpInfoXml(currentVersion, nextVersion) + bumpPackageJson(nextVersion) + commitChanges(nextVersion) + + printDone(currentBranch) +} + +main().catch((err) => { + print.err(err.message) + process.exit(1) +}) diff --git a/scripts/cli-utils.mjs b/scripts/cli-utils.mjs new file mode 100644 index 00000000000..105a8cc2405 --- /dev/null +++ b/scripts/cli-utils.mjs @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Generic CLI helpers shared by the release scripts: coloured terminal + * output, fail-fast subprocess runners, and read-only GitHub REST API + * access. Writes are never made here, only printed via print.command. + */ + +import { spawnSync } from 'node:child_process' +import process from 'node:process' + +// --- Colours --------------------------------------------------------------- +export const COLOR = { + RED: '\x1b[0;31m', + GREEN: '\x1b[0;32m', + YELLOW: '\x1b[1;33m', + BLUE: '\x1b[0;34m', + CYAN: '\x1b[0;36m', + WHITE: '\x1b[0;37m', + NC: '\x1b[0m', +} + +const BAR = '━'.repeat(45) + +/** The single terminal-output surface for the release scripts. */ +export const print = { + // Status messages + info: (message) => console.info(`${COLOR.BLUE}➜${COLOR.NC} ${message}`), + ok: (message) => console.info(`${COLOR.GREEN}✔${COLOR.NC} ${message}`), + warn: (message) => console.warn(`${COLOR.YELLOW}!${COLOR.NC} ${message}`), + err: (message) => console.error(`${COLOR.RED}✖${COLOR.NC} ${message}`), + + // Report layout. `message` may be a single line or an array of lines. + header: (message) => { + console.info('') + console.info(`${COLOR.BLUE}${BAR}${COLOR.NC}`) + for (const line of Array.isArray(message) ? message : [message]) { + console.info(`${COLOR.BLUE}${line}${COLOR.NC}`) + } + console.info(`${COLOR.BLUE}${BAR}${COLOR.NC}`) + }, + section: (message) => { + console.info('') + console.info(`${COLOR.CYAN}→ ${message}${COLOR.NC}`) + }, + item: (message) => console.info(` • ${message}`), + note: (message) => console.info(` ${message}`), + + // A `gh` command line for the reader to copy, run and verify themselves. + command: (cmd) => console.info(` ${COLOR.WHITE}$ ${cmd}${COLOR.NC}`), + + // Raw line (default: a blank line) + log: (message = '') => console.info(message), +} + +// --- Subprocess runners ---------------------------------------------------- + +/** + * Spawn a command synchronously and return its raw result. + * + * @param {string} cmd command to run + * @param {string[]} args arguments + * @param {object} [options] extra spawn options + * @return {import('node:child_process').SpawnSyncReturns} the result + */ +function spawn(cmd, args, options = {}) { + return spawnSync(cmd, args, { encoding: 'utf-8', ...options }) +} + +/** + * 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} trimmed stdout when `capture` is set, else '' + */ +export function run(cmd, args, options = {}) { + const { capture = false, ...rest } = options + const result = spawn(cmd, args, { stdio: capture ? ['inherit', 'pipe', 'inherit'] : 'inherit', ...rest }) + if (result.status !== 0) { + print.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 = spawn(cmd, args, options) + return result.status === 0 ? (result.stdout ?? '').trim() : null +} + +// --- GitHub REST API (read-only) -------------------------------------------- +// +// nextcloud/spreed is public: plain `fetch` against api.github.com, no auth +// needed. GH_TOKEN/GITHUB_TOKEN, if set, raises the rate limit to 5,000/hr. + +const GITHUB_API = 'https://api.github.com' + +/** + * The token the user explicitly opted into, if any — GH_TOKEN or GITHUB_TOKEN. + * + * @return {string|null} the token, or null when neither var is set + */ +function getAuthToken() { + return process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null +} + +/** + * Headers for a GitHub REST API request, with an Authorization header added + * when a token is available. + * + * @return {Record} the headers + */ +function githubApiHeaders() { + const headers = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } + const token = getAuthToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + return headers +} + +/** + * GET one page from the GitHub REST API and parse it as JSON. + * + * @param {string} path an API path, e.g. '/repos/nextcloud/spreed/pulls/123' + * @return {Promise<*>} the parsed JSON body + */ +export async function ghFetch(path) { + const url = `${GITHUB_API}${path}` + const res = await fetch(url, { headers: githubApiHeaders() }) + if (!res.ok) { + print.err(`GitHub API request failed: ${res.status} ${res.statusText} — ${url}`) + process.exit(1) + } + return res.json() +} + +/** + * GET every page of a paginated GitHub REST API listing, following the + * response's `Link` header, and return the concatenated results. + * + * @param {string} path an API path; include `per_page=100` for fewer round-trips + * @return {Promise>} all items across every page + */ +export async function ghFetchAll(path) { + let url = `${GITHUB_API}${path}` + const results = [] + while (url) { + const res = await fetch(url, { headers: githubApiHeaders() }) + if (!res.ok) { + print.err(`GitHub API request failed: ${res.status} ${res.statusText} — ${url}`) + process.exit(1) + } + results.push(...(await res.json())) + + const link = res.headers.get('link') || '' + const next = link.split(',').find((part) => part.includes('rel="next"')) + url = next ? next.split(';')[0].trim().slice(1, -1) : null + } + return results +} + +/** + * Whether a git ref resolves (branch, tag or HEAD). + * + * @param {string} ref the ref to check + * @return {boolean} true when the ref exists + */ +export function branchExists(ref) { + return tryRead('git', ['rev-parse', '--verify', ref]) !== null +} + +/** Exit if the repository has staged, unstaged, or untracked changes. */ +export function requireCleanWorktree() { + const status = run('git', ['status', '--porcelain'], { capture: true }) + if (status) { + print.err('Working directory is not clean; commit, stash, or remove local changes first') + process.exit(1) + } +} + +// Binaries with a more specific "not found" message than the generic one below. +const MISSING_BIN_MESSAGES = { + git: 'git is not installed', +} + +/** + * Check that required binaries are on PATH. When 'git' is listed, also + * requires the current directory to be a git repository. + * + * @param {string[]} bins required binaries, checked via ` --version` + * @return {boolean} true when every check passed + */ +export function preflight(bins) { + let ok = true + const passed = new Set() + for (const bin of bins) { + if (tryRead(bin, ['--version']) === null) { + print.err(MISSING_BIN_MESSAGES[bin] ?? `Required command '${bin}' not found in PATH.`) + ok = false + } else { + passed.add(bin) + } + } + if (passed.has('git') && tryRead('git', ['rev-parse', '--git-dir']) === null) { + print.err('Not in a git repository') + ok = false + } + return ok +} + +/** + * Parse `process.argv.slice(2)`-style arguments against a set of flags, with + * unmatched arguments passed to `onPositional`. `-h`/`--help` calls `usage`. + * + * @param {string[]} argv arguments to parse + * @param {object} spec parsing spec + * @param {Record void>} spec.flags map of flag name to handler + * @param {(arg: string) => void} spec.onPositional called for non-flag arguments + * @param {() => void} spec.usage prints usage and exits; invoked on -h/--help or an unknown flag + */ +export function parseArgs(argv, { flags = {}, onPositional, usage }) { + for (const arg of argv) { + if (arg === '-h' || arg === '--help') { + usage() + } else if (arg in flags) { + flags[arg]() + } else if (arg.startsWith('-')) { + print.err(`Unknown argument: ${arg}`) + usage() + } else { + onPositional(arg) + } + } +} diff --git a/scripts/prepare-changelog.mjs b/scripts/prepare-changelog.mjs new file mode 100644 index 00000000000..79cc9c1c07c --- /dev/null +++ b/scripts/prepare-changelog.mjs @@ -0,0 +1,376 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Generate changelog entries for the given stable branches from their merged + * milestone PRs, and commit them locally — one commit per branch on a new + * `chore/release/changelog-*` branch (push and PR stay manual). + * + * Requires: git. + * + * Usage: + * node scripts/prepare-changelog.mjs [options] [stable-branch...] + * + * Arguments: + * stable-branch Specific stable branches to check (e.g. stable33 stable34). + * Defaults to maintained versions from dependabot.yml. + * + * Options: + * --dry-run Preview changelog output without making any changes + * -h, --help Show this help + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import process from 'node:process' +import { branchExists, COLOR, ghFetchAll, parseArgs, preflight, print, requireCleanWorktree, run } from './cli-utils.mjs' +import { detectStableBranches, existingOriginBranches, fetchOrigin, getCurrentBranch, incrementVersion, readBranchInfoVersion, today, todayCompact } from './release-utils.mjs' + +const REPO = 'nextcloud/spreed' + +/** Print usage information and exit. */ +function usage() { + print.log(`Usage: node scripts/prepare-changelog.mjs [OPTIONS] [STABLE_BRANCH...] + +Generate changelog entries for the given stable branches and commit them +locally (push and PR stay manual). + +OPTIONS: + --dry-run Preview changelog output without making any changes + -h, --help Show this help message + +ARGUMENTS: + STABLE_BRANCH Specific stable branches to check (e.g., stable33, stable34) + If not provided, automatically selects maintained versions from dependabot.yml + +EXAMPLES: + node scripts/prepare-changelog.mjs # Prepare changelog for maintained stable branches + node scripts/prepare-changelog.mjs stable33 stable34 # Prepare changelog for specific branches + node scripts/prepare-changelog.mjs --dry-run # Preview changelog without making changes +`) + process.exit(0) +} + +/** + * Parse CLI arguments. + * + * @return {{dryRun: boolean, stableBranches: string[]}} parsed options + */ +function parseArguments() { + let dryRun = false + const stableBranches = [] + + parseArgs(process.argv.slice(2), { + usage, + flags: { + '--dry-run': () => { + dryRun = true + }, + }, + onPositional: (arg) => stableBranches.push(arg), + }) + + return { + dryRun, + stableBranches: stableBranches.length > 0 ? stableBranches : detectStableBranches(), + } +} + +/** Check git is installed and usable; exit if not. */ +function checkPreflight() { + if (!preflight(['git'])) { + process.exit(1) + } + print.ok('All required tools available') +} + +/** + * Fetch remote info and figure out which branches exist on origin. + * + * @param {string[]} stableBranches the branches to check + * @return {Set} which of those branches exist on origin + */ +function gitSetup(stableBranches) { + const currentBranch = getCurrentBranch() + print.note(`Current branch: ${currentBranch}`) + + fetchOrigin() + + return existingOriginBranches(stableBranches) +} + +/** + * Build a formatted changelog section from a milestone's merged PRs. + * + * @param {number|string} milestoneNumber the milestone id + * @param {string} sectionVersion the version the section documents + * @return {Promise} the markdown changelog section + */ +async function generateChangelogSection(milestoneNumber, sectionVersion) { + const prData = await ghFetchAll(`/repos/${REPO}/issues?milestone=${milestoneNumber}&state=closed&per_page=100`) + + let hasDeps = false + const entriesAdded = [] + const entriesFixed = [] + const entriesRemoved = [] + + for (const issue of prData) { + if (!issue.pull_request) { + continue + } + // Strip [stableXX] backport prefix + const title = issue.title.replace(/^\[stable[0-9.]*\] /, '') + + if (/^(chore|build)\(deps/.test(title)) { + hasDeps = true + continue + } + + const link = ` [#${issue.number}](https://github.com/nextcloud/spreed/pull/${issue.number})` + const entry = `- ${title}\n${link}` + + if (/^feat/.test(title)) { + entriesAdded.push(entry) + } else if (/^fix/.test(title)) { + entriesFixed.push(entry) + } else if (/^revert/.test(title)) { + entriesRemoved.push(entry) + } + // docs/ci/chore/perf/refactor/build/test entries are omitted + } + + const lines = [`## ${sectionVersion} – ${today()}`] + + if (entriesAdded.length > 0) { + lines.push('### Added') + lines.push(...entriesAdded) + lines.push('') + } + + lines.push('### Changed') + if (hasDeps) { + lines.push('- Update dependencies') + } + lines.push('- Update translations') + lines.push('') + + if (entriesFixed.length > 0) { + lines.push('### Fixed') + lines.push(...entriesFixed) + lines.push('') + } + + if (entriesRemoved.length > 0) { + lines.push('### Removed') + lines.push(...entriesRemoved) + lines.push('') + } + + return lines.join('\n') +} + +/** + * Insert a changelog section before the first "## " heading, creating the + * file with a standard header when it does not exist yet. + * + * @param {string} file the changelog file path + * @param {string} content the section to prepend + */ +function prependChangelogSection(file, content) { + if (!existsSync(file)) { + // REUSE-IgnoreStart -- another file's SPDX header, not this file's own + const headerBlock = [ + '', + // REUSE-IgnoreEnd + '# Changelog', + 'All notable changes to this project will be documented in this file.', + '', + content, + '', + ].join('\n') + writeFileSync(file, headerBlock) + return + } + + const fileLines = readFileSync(file, 'utf-8').split('\n') + const firstSectionIndex = fileLines.findIndex((l) => l.startsWith('## ')) + + let result + if (firstSectionIndex === -1) { + result = `${fileLines.join('\n')}\n${content}\n` + } else { + const before = fileLines.slice(0, firstSectionIndex) + const after = fileLines.slice(firstSectionIndex) + result = `${before.join('\n')}\n${content}\n\n${after.join('\n')}` + } + writeFileSync(file, result) +} + +/** + * Gather one changelog-ready entry per branch with a version, matching + * milestone, and generated changelog section. + * + * @param {string[]} stableBranches the branches to check + * @param {Set} existingStableBranches which of those exist on origin + * @param {Array} milestonesJson all milestones + * @return {Promise>} one entry per ready branch + */ +async function gatherChangelogReleases(stableBranches, existingStableBranches, milestonesJson) { + const releases = [] + + for (const branch of stableBranches) { + if (!existingStableBranches.has(branch)) { + continue + } + + const ncMajor = (branch.match(/[0-9.]+/) || [''])[0] + const branchVersion = readBranchInfoVersion(branch) + + if (!branchVersion) { + print.warn(`${branch}: could not read version from appinfo/info.xml`) + continue + } + + const talkMajor = branchVersion.split('.')[0] + + // Match any "... (version)" milestone (Next Patch/RC/Major) + const milestoneData = milestonesJson.find((m) => new RegExp(`\\(${ncMajor}\\)`).test(m.title)) + + if (!milestoneData) { + print.warn(`${branch}: no milestone with '(${ncMajor})' found`) + continue + } + + const milestoneNumber = milestoneData.number + const milestoneTitle = milestoneData.title + const milestoneOpen = milestoneData.open_issues + + const nextVersion = incrementVersion(branchVersion) + if (nextVersion === null) { + print.warn(`${branch}: appinfo/info.xml contains invalid semver '${branchVersion}'`) + continue + } + + print.item(`${branch}: v${branchVersion} → v${nextVersion} ← ${milestoneTitle} (${milestoneOpen} open issues)`) + + const changelogSection = await generateChangelogSection(milestoneNumber, nextVersion) + + releases.push({ branch, major: talkMajor, nextVersion, changelogSection }) + } + + return releases +} + +/** + * Preview the changelog commits a real run would create, without touching git. + * + * @param {string} prBranch the branch name the real run would create + * @param {Array<{major: string, nextVersion: string, changelogSection: string}>} releases the changelog-ready releases + */ +function previewChangelogCommits(prBranch, releases) { + print.note(`Branch: ${prBranch} (from main)`) + + for (const { major, nextVersion, changelogSection } of releases) { + const changelogFile = `docs/changelogs/changelog-${major}.md` + print.log() + print.note(`Commit: chore(release): Changelog for v${nextVersion}`) + print.log(` ${COLOR.CYAN}--- a/${changelogFile}${COLOR.NC}`) + print.log(` ${COLOR.CYAN}+++ b/${changelogFile}${COLOR.NC}`) + for (const line of changelogSection.split('\n')) { + print.log(` ${COLOR.GREEN}+${line}${COLOR.NC}`) + } + } + print.log() +} + +/** + * Create the changelog branch and commit one changelog file per release. + * + * @param {string} prBranch the branch name to create + * @param {Array<{major: string, nextVersion: string, changelogSection: string}>} releases the changelog-ready releases + * @param {Array} milestonesJson all milestones, for the PR's next-major hint + * @param {string} versionsStr the joined "vX, vY" versions, for the PR title + */ +function commitChangelogBranch(prBranch, releases, milestonesJson, versionsStr) { + run('git', ['checkout', '-b', prBranch, 'origin/main']) + + let commitCount = 0 + + for (const { major, nextVersion, changelogSection } of releases) { + const changelogFile = `docs/changelogs/changelog-${major}.md` + prependChangelogSection(changelogFile, changelogSection) + run('git', ['add', changelogFile]) + run('git', ['commit', '-s', '-m', `chore(release): Changelog for v${nextVersion}`]) // -s for DCO + print.ok(`Committed ${changelogFile}`) + commitCount++ + } + + const nextMajorMilestones = milestonesJson + .filter((m) => /Next Major/.test(m.title)) + .sort((a, b) => a.title.localeCompare(b.title)) + const nextMajorMilestone = nextMajorMilestones.length > 0 + ? nextMajorMilestones[nextMajorMilestones.length - 1].title + : '' + const milestoneFlag = nextMajorMilestone ? `--milestone "${nextMajorMilestone}" ` : '' + + print.log() + print.ok(`Branch '${prBranch}' ready — review and adjust the changelog, then:`) + print.log(` git push -u origin ${prBranch}`) + print.log(` gh pr create --title "chore(release): Changelog for ${versionsStr}" --base main --assignee @me ${milestoneFlag}--body "$(git diff HEAD~${commitCount}..HEAD -- docs/changelogs/ | grep '^+[^+]' | sed 's/^+//')" --repo nextcloud/spreed`) +} + +/** Run changelog generation or its read-only preview. */ +async function main() { + const { dryRun, stableBranches } = parseArguments() + + print.header('Nextcloud Spreed Changelog Preparation') + + checkPreflight() + + if (dryRun) { + print.log(`${COLOR.YELLOW}[DRY RUN – no changes will be made]${COLOR.NC}`) + } else { + requireCleanWorktree() + } + + if (stableBranches.length === 0) { + print.err('No stable branches found or given') + process.exit(1) + } + print.log(`Target branches: ${COLOR.BLUE}${stableBranches.join(',')}${COLOR.NC}`) + + const existingStableBranches = gitSetup(stableBranches) + + const milestonesJson = await ghFetchAll(`/repos/${REPO}/milestones?state=open&per_page=100`) + + print.section('Changelog') + + const releases = await gatherChangelogReleases(stableBranches, existingStableBranches, milestonesJson) + + if (releases.length === 0) { + print.note('Nothing to generate') + return + } + + print.section('Preparing Changelog Commits') + + const prBranch = `chore/release/changelog-${todayCompact()}` + const versionsStr = releases.map((r) => `v${r.nextVersion}`).join(', ') + + if (dryRun) { + previewChangelogCommits(prBranch, releases) + } else if (branchExists(prBranch)) { + print.warn(`Branch '${prBranch}' already exists — delete it first or use a different date suffix`) + } else { + commitChangelogBranch(prBranch, releases, milestonesJson, versionsStr) + } +} + +main().catch((err) => { + print.err(err.message) + process.exit(1) +}) diff --git a/scripts/release-utils.mjs b/scripts/release-utils.mjs new file mode 100644 index 00000000000..363be872d25 --- /dev/null +++ b/scripts/release-utils.mjs @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Release-specific helpers shared by the release scripts: version parsing, + * bumping, date formatting, stable-branch detection and shared git state. + */ + +import { existsSync, readFileSync } from 'node:fs' +import semver from 'semver' +import { branchExists, print, run, tryRead } from './cli-utils.mjs' + +/** + * Extract the app version from an appinfo/info.xml string. + * + * @param {string|null} xml the file contents + * @return {string} the version, or '' when not found + */ +export function parseInfoVersion(xml) { + if (!xml) { + return '' + } + const match = xml.match(/([^<]*)<\/version>/) + return match ? match[1] : '' +} + +/** + * Read and parse the version from a local appinfo/info.xml. + * + * @param {string} [path] path to the info.xml file + * @return {string} the version, or '' when the file is missing or has no version + */ +export function readLocalInfoVersion(path = 'appinfo/info.xml') { + return existsSync(path) ? parseInfoVersion(readFileSync(path, 'utf-8')) : '' +} + +/** + * Increment a version string: the prerelease number for an RC, otherwise the patch. + * e.g. 24.0.0-rc.3 → 24.0.0-rc.4 | 23.0.5 → 23.0.6 + * + * @param {string} version the version to bump + * @return {string|null} the incremented version, or null when not valid semver + */ +export function incrementVersion(version) { + if (!semver.valid(version)) { + return null + } + const type = semver.prerelease(version) ? 'prerelease' : 'patch' + return semver.inc(version, type) +} + +/** + * Today's date as YYYY-MM-DD. + * + * @return {string} the ISO date + */ +export function today() { + return new Date().toISOString().slice(0, 10) +} + +/** + * Today's date as YYYYMMDD. + * + * @return {string} the compact date + */ +export function todayCompact() { + return today().replace(/-/g, '') +} + +/** + * Descending "version sort" for stableNN branch names (mimics `sort -Vr`). + * + * @param {string} a first branch name + * @param {string} b second branch name + * @return {number} comparison result + */ +function compareVersionsDesc(a, b) { + return semver.rcompare(semver.coerce(a), semver.coerce(b)) +} + +/** + * Auto-detect maintained stable branches from dependabot.yml, falling back + * to the top 3 remote stable branches. + * + * @return {string[]} the detected branch names, highest version first + */ +export function detectStableBranches() { + if (existsSync('.github/dependabot.yml')) { + const content = readFileSync('.github/dependabot.yml', 'utf-8') + const found = new Set() + for (const line of content.split('\n')) { + if (line.includes('target-branch:')) { + const m = line.match(/stable[0-9.]+/) + if (m) { + found.add(m[0]) + } + } + } + return [...found].sort(compareVersionsDesc) + } + + const remote = tryRead('git', ['branch', '-r']) || '' + const found = new Set() + for (const line of remote.split('\n')) { + const m = line.match(/origin\/(stable[0-9.]+)/) + if (m) { + found.add(m[1]) + } + } + return [...found].sort(compareVersionsDesc).slice(0, 3) +} + +/** + * The current branch name. + * + * @return {string} the current branch name + */ +export function getCurrentBranch() { + return run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) +} + +/** Fetch from origin, warning (not failing) when it doesn't work. */ +export function fetchOrigin() { + print.note('Fetching remote info...') + if (tryRead('git', ['fetch', 'origin', '--quiet']) === null) { + print.warn('Could not fetch from origin') + } +} + +/** + * Which of the given branches exist on origin. + * + * @param {string[]} branches branch names to check + * @return {Set} the subset that exist as origin/ + */ +export function existingOriginBranches(branches) { + return new Set(branches.filter((b) => branchExists(`origin/${b}`))) +} + +/** + * Read and parse the appinfo/info.xml version from a remote branch. + * + * @param {string} branch the branch to read from + * @return {string} the version, or '' when not found + */ +export function readBranchInfoVersion(branch) { + return parseInfoVersion(tryRead('git', ['show', `origin/${branch}:appinfo/info.xml`])) +} diff --git a/scripts/update-milestones.mjs b/scripts/update-milestones.mjs new file mode 100644 index 00000000000..658d4967aa3 --- /dev/null +++ b/scripts/update-milestones.mjs @@ -0,0 +1,327 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Roll the milestones over after a release, per the "Rename milestone..." + * block of the release checklist (https://github.com/nextcloud/spreed/issues/5879): + * 1. Rename the open ' Next Patch/RC/Major (X)' milestone to 'vX.Y.Z' + * 2. Create its follow-up milestone, same emoji (skipped with --last) + * 3. Move open issues to the follow-up milestone (with --last: to the + * open '(X+1)' milestone instead; warns if none exists) + * 4. Move open PRs to the follow-up milestone (with --last: never moved, + * just warns for manual triage) + * 5. Close the 'vX.Y.Z' milestone + * + * Follow-up milestone's flavour/due date derive from the released version: + * same flavour unless no prerelease tag remains (then "Next Patch"); due in + * 4 weeks, or 1 week for a prerelease tag (e.g. -rc.2). + * + * Version is read from the branch's appinfo/info.xml, same as + * validate-release.mjs and prepare-changelog.mjs. + * + * Read-only against GitHub — every write is a `gh` command printed for a + * human to run. + * + * Requires: git. + * + * Usage: + * node scripts/update-milestones.mjs [options] + * + * Arguments: + * The stable branch that was just released, e.g. stable33 + * + * Options: + * --last Last release of this branch — skip the follow-up milestone; + * plan moving open issues to the next major's '(X+1)' milestone, + * warn about open PRs instead + * -h, --help Show this help + */ + +import process from 'node:process' +import semver from 'semver' +import { branchExists, ghFetchAll, parseArgs, preflight, print, tryRead } from './cli-utils.mjs' +import { fetchOrigin, parseInfoVersion, readBranchInfoVersion } from './release-utils.mjs' + +const REPO = 'nextcloud/spreed' + +/** Print usage information and exit. */ +function usage() { + print.log(`Usage: node scripts/update-milestones.mjs [OPTIONS] + +Roll the milestones over after releasing : rename the open +"Next Patch/RC/Major (X)" milestone matching the Nextcloud stable branch +number to "vX.Y.Z" (version read from the branch's appinfo/info.xml), plan +its follow-up milestone (same emoji, flavour and due date derived from the +version) and moving open issues/PRs over, then close "vX.Y.Z". + +This never writes to GitHub itself — it prints the exact 'gh' commands for +each step, for you to copy, run and verify. + +ARGUMENTS: + STABLE_BRANCH The stable branch that was just released, e.g. stable33 + +OPTIONS: + --last Last release of this branch — skip the follow-up milestone; + plan moving open issues to the next major's '(X+1)' milestone, + warn about open PRs instead + -h, --help Show this help message + +EXAMPLES: + node scripts/update-milestones.mjs stable33 + node scripts/update-milestones.mjs stable34 --last +`) + process.exit(0) +} + +/** + * Parse CLI arguments. + * + * @return {{branch: string, last: boolean}} parsed options + */ +function parseArguments() { + let branch = null + let last = false + + parseArgs(process.argv.slice(2), { + usage, + flags: { + '--last': () => { + last = true + }, + }, + onPositional: (arg) => { + if (!branch) { + branch = arg + } + }, + }) + + if (!branch) { + print.err('A stable branch is required, e.g. stable33') + usage() + } + + return { branch, last } +} + +/** Check required tools are available; exit if not. */ +function checkPreflight() { + if (!preflight(['git'])) { + process.exit(1) + } +} + +/** + * Resolve the branch's version from appinfo/info.xml: local branch first (no + * network needed), else fetch and read origin/. + * + * @param {string} branch the stable branch to read + * @return {string} the version, or '' when it could not be determined + */ +function resolveBranchVersion(branch) { + if (branchExists(branch)) { + print.note(`Using local branch '${branch}'`) + return parseInfoVersion(tryRead('git', ['show', `${branch}:appinfo/info.xml`])) + } + + fetchOrigin() + if (!branchExists(`origin/${branch}`)) { + print.err(`Branch '${branch}' not found locally or on origin`) + process.exit(1) + } + return readBranchInfoVersion(branch) +} + +/** + * Compute the follow-up milestone's due date: 4 weeks out normally, 1 week + * for a prerelease tag (e.g. 24.0.0-rc.2 — beta/RC cadence). + * + * @param {string} version the released version + * @return {string} an ISO date-time, e.g. '2026-09-28T00:00:00Z' + */ +function computeDueDate(version) { + const days = semver.prerelease(version) ? 7 : 28 + const date = new Date() + date.setUTCDate(date.getUTCDate() + days) + return `${date.toISOString().slice(0, 10)}T00:00:00Z` +} + +/** + * Find a milestone by exact title. + * + * @param {string} title the milestone title to look for + * @param {Array} milestones all milestones + * @return {object|undefined} the milestone, or undefined when not found + */ +function findMilestoneByTitle(title, milestones) { + return milestones.find((m) => m.title === title) +} + +/** + * Find the open "next" milestone for a Nextcloud stable branch number, + * whatever flavour — Next Patch/RC/Major (X). Mirrors prepare-changelog.mjs. + * + * @param {string} ncMajor the Nextcloud stable branch number, e.g. '33' for 'stable33' + * @param {Array} milestones all milestones + * @return {object|undefined} the milestone, or undefined when not found + */ +function findNextMilestone(ncMajor, milestones) { + const pattern = new RegExp(`\\(${ncMajor}\\)$`) + return milestones.find((m) => m.state === 'open' && pattern.test(m.title)) +} + +/** + * Derive the follow-up milestone's title: same emoji and flavour, unless the + * released version has no prerelease tag anymore (branch gone stable), in + * which case it's always "Next Patch". + * + * @param {string} patchTitle the current milestone's title + * @param {string} version the released version + * @param {string} ncMajor the Nextcloud stable branch number + * @return {string} the follow-up milestone's title + */ +function deriveNextPatchTitle(patchTitle, version, ncMajor) { + const titleMatch = patchTitle.match(/^(\S+)\s+(.+?)\s+\(\d[\d.]*\)$/) + const emoji = titleMatch?.[1] ?? '💚' + const flavour = semver.prerelease(version) ? (titleMatch?.[2] ?? 'Next Patch') : 'Next Patch' + return `${emoji} ${flavour} (${ncMajor})` +} + +/** + * Fetch everything open on a milestone, split into issues and PRs (a PR is + * any item carrying a `pull_request` field). + * + * @param {number} milestoneNumber the milestone's number + * @return {Promise<{issues: Array<{number: number, title: string}>, prs: Array<{number: number, title: string}>}>} the open issues and PRs + */ +async function listOpenOnMilestone(milestoneNumber) { + const items = await ghFetchAll(`/repos/${REPO}/issues?milestone=${milestoneNumber}&state=open&per_page=100`) + return { + issues: items.filter((i) => !i.pull_request), + prs: items.filter((i) => i.pull_request), + } +} + +/** + * Print a single shell loop that moves every listed issue/PR to a milestone. + * + * @param {'issue'|'pr'} kind which `gh` subcommand to loop + * @param {Array<{number: number}>} items the issues or PRs to move + * @param {string} milestoneTitle the destination milestone's title + */ +function printMoveCommand(kind, items, milestoneTitle) { + const numbers = items.map((i) => i.number).join(' ') + print.command(`for n in ${numbers}; do gh ${kind} edit "$n" --repo ${REPO} --milestone "${milestoneTitle}"; done`) +} + +/** + * Print the full plan: each checklist step and the `gh` command for it. + * + * @param {object} plan the computed plan + */ +function printPlan(plan) { + const { patchTitle, patchMilestone, nextPatchTitle, nextMajorMilestone, nextNcMajor, releaseTitle, dueDate, last, openIssues, openPrs } = plan + + print.section(`1. Rename '${patchTitle}' (#${patchMilestone.number}) → '${releaseTitle}'`) + print.command(`gh api --method PATCH repos/${REPO}/milestones/${patchMilestone.number} -f title="${releaseTitle}"`) + + if (last) { + print.note("--last given: no follow-up milestone — this branch's line is done") + + print.section(`3. Move open issues from '${releaseTitle}' to the '(${nextNcMajor})' milestone`) + if (openIssues.length === 0) { + print.ok('No open issues to move') + } else if (!nextMajorMilestone) { + print.warn(`No open milestone matching '(${nextNcMajor})' found — ${openIssues.length} issue(s) need manual triage`) + } else { + print.note(`${openIssues.length} issue(s) → '${nextMajorMilestone.title}'`) + printMoveCommand('issue', openIssues, nextMajorMilestone.title) + } + + print.section('4. Open PRs') + if (openPrs.length === 0) { + print.ok('No open PRs left on this milestone') + } else { + print.warn(`${openPrs.length} open PR(s) not moved — triage manually`) + } + } else { + print.section(`2. Create milestone '${nextPatchTitle}', due ${dueDate.slice(0, 10)}`) + print.command(`gh api --method POST repos/${REPO}/milestones -f title="${nextPatchTitle}" -f due_on="${dueDate}"`) + + print.section(`3. Move open issues from '${releaseTitle}' to '${nextPatchTitle}'`) + if (openIssues.length === 0) { + print.ok('No open issues to move') + } else { + print.note(`${openIssues.length} issue(s)`) + printMoveCommand('issue', openIssues, nextPatchTitle) + } + + print.section(`4. Move open PRs from '${releaseTitle}' to '${nextPatchTitle}'`) + if (openPrs.length === 0) { + print.ok('No open PRs to move') + } else { + print.note(`${openPrs.length} PR(s)`) + printMoveCommand('pr', openPrs, nextPatchTitle) + } + } + + print.section(`5. Close milestone '${releaseTitle}'`) + print.command(`gh api --method PATCH repos/${REPO}/milestones/${patchMilestone.number} -f state=closed`) +} + +/** Run the milestone rollover plan. */ +async function main() { + const { branch, last } = parseArguments() + + print.header(`Nextcloud Spreed Milestone Rollover — ${branch}`) + + checkPreflight() + + const version = resolveBranchVersion(branch) + if (!version) { + print.err(`Could not read version from ${branch}:appinfo/info.xml`) + process.exit(1) + } + print.note(`${branch} is at v${version}`) + + // '(X)' is the Nextcloud stable branch number, not Talk's own version — + // same convention as prepare-changelog.mjs's ncMajor. + const ncMajor = (branch.match(/[0-9.]+/) || [''])[0] + const releaseTitle = `v${version}` + const dueDate = computeDueDate(version) + + const milestones = await ghFetchAll(`/repos/${REPO}/milestones?state=all&per_page=100`) + + const patchMilestone = findNextMilestone(ncMajor, milestones) + if (!patchMilestone) { + print.err(`No open milestone matching '(${ncMajor})' found — expected e.g. 'Next Patch (${ncMajor})', 'Next RC (${ncMajor})' or 'Next Major (${ncMajor})'`) + process.exit(1) + } + const patchTitle = patchMilestone.title + print.note(`Rolling over: '${patchTitle}' (#${patchMilestone.number})`) + + const nextPatchTitle = deriveNextPatchTitle(patchTitle, version, ncMajor) + // With --last, open issues go to the next Nextcloud major's milestone — + // e.g. rolling over stable33's last release looks for open '(34)'. + const nextNcMajor = String(Number(ncMajor) + 1) + const nextMajorMilestone = last ? findNextMilestone(nextNcMajor, milestones) : undefined + + const existingRelease = findMilestoneByTitle(releaseTitle, milestones) + if (existingRelease) { + print.err(`Milestone '${releaseTitle}' already exists (#${existingRelease.number}) — nothing to rename into`) + process.exit(1) + } + + const { issues: openIssues, prs: openPrs } = await listOpenOnMilestone(patchMilestone.number) + + const plan = { patchTitle, patchMilestone, nextPatchTitle, nextMajorMilestone, nextNcMajor, releaseTitle, dueDate, last, openIssues, openPrs } + + printPlan(plan) +} + +main().catch((err) => { + print.err(err.message) + process.exit(1) +}) diff --git a/scripts/validate-release.mjs b/scripts/validate-release.mjs new file mode 100644 index 00000000000..dc421ecc0d1 --- /dev/null +++ b/scripts/validate-release.mjs @@ -0,0 +1,439 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Pre-release preparation report for Nextcloud Spreed. Gathers pending + * backports, milestone status, open PRs and dependabot coverage for the + * stable branches. Read-only — see prepare-changelog.mjs to generate and + * commit changelog entries. + * + * Requires: git. + * + * Usage: + * node scripts/validate-release.mjs [options] [stable-branch...] + * + * Arguments: + * stable-branch Specific stable branches to check (e.g. stable33 stable34). + * Defaults to maintained versions from dependabot.yml. + * + * Options: + * --verbose Show detailed output + * -h, --help Show this help + */ + +import { existsSync, readFileSync } from 'node:fs' +import process from 'node:process' +import { COLOR, ghFetchAll, parseArgs, preflight, print, run, tryRead } from './cli-utils.mjs' +import { detectStableBranches, existingOriginBranches, fetchOrigin, getCurrentBranch, readBranchInfoVersion, readLocalInfoVersion } from './release-utils.mjs' + +const REPO = 'nextcloud/spreed' + +/** Print usage information and exit. */ +function usage() { + print.log(`Usage: node scripts/validate-release.mjs [OPTIONS] [STABLE_BRANCH...] + +Gather release preparation information for Nextcloud Spreed + +Run from 'main' branch to report on upcoming releases. + +OPTIONS: + --verbose Show detailed output + -h, --help Show this help message + +ARGUMENTS: + STABLE_BRANCH Specific stable branches to check (e.g., stable33, stable34) + If not provided, automatically selects maintained versions from dependabot.yml + +EXAMPLES: + node scripts/validate-release.mjs # Check maintained stable branches + node scripts/validate-release.mjs stable33 stable34 # Check specific branches + node scripts/validate-release.mjs --verbose # Show every open issue, not just high-priority +`) + process.exit(0) +} + +/** + * Parse CLI arguments. + * + * @return {{verbose: boolean, stableBranches: string[]}} parsed options + */ +function parseArguments() { + let verbose = false + const stableBranches = [] + + parseArgs(process.argv.slice(2), { + usage, + flags: { + '--verbose': () => { + verbose = true + }, + }, + onPositional: (arg) => stableBranches.push(arg), + }) + + return { + verbose, + stableBranches: stableBranches.length > 0 ? stableBranches : detectStableBranches(), + } +} + +/** Check git is installed and usable; exit if not. */ +function checkPreflight() { + const ok = preflight(['git']) + + if (!ok) { + process.exit(1) + } + + print.ok('All required tools available') +} + +/** + * Print the target scope. + * + * @param {string[]} stableBranches the branches being checked + */ +function printScope(stableBranches) { + if (stableBranches.length === 0) { + print.log(`Scope: ${COLOR.BLUE}main branch (preparation only)${COLOR.NC}`) + } else { + print.log(`Target branches: ${COLOR.BLUE}${stableBranches.join(',')}${COLOR.NC}`) + } +} + +/** + * Fetch remote info and figure out which branches exist on origin. + * + * @param {string[]} stableBranches the branches to check + * @return {{currentBranch: string, existingStableBranches: Set}} git state + */ +function gitSetup(stableBranches) { + const currentBranch = getCurrentBranch() + print.note(`Current branch: ${currentBranch}`) + + fetchOrigin() + + // Checked once, reused below instead of re-running `git rev-parse` per section. + const existingStableBranches = existingOriginBranches(stableBranches) + + return { currentBranch, existingStableBranches } +} + +/** Report the version in appinfo/info.xml and package.json, warning on mismatch. */ +function reportVersionInfo() { + print.section('Version Information') + + const version = readLocalInfoVersion() + if (version) { + print.note(`appinfo/info.xml: ${version}`) + } + + let pkgVersion = '' + if (existsSync('package.json')) { + try { + pkgVersion = JSON.parse(readFileSync('package.json', 'utf-8')).version || '' + } catch { + pkgVersion = '' + } + if (pkgVersion) { + print.note(`package.json: ${pkgVersion}`) + } + } + + if (version && pkgVersion && version !== pkgVersion) { + print.warn('Version mismatch between appinfo/info.xml and package.json') + } +} + +/** + * Fetch all open PRs once, filtered client-side by sections 1 and 3. Uses + * the Pull Requests API (not Issues), since that's what carries `.base.ref`. + * + * @return {Promise>} the open PRs + */ +function fetchOpenPrs() { + return ghFetchAll(`/repos/${REPO}/pulls?state=open&per_page=100`) +} + +/** + * Section 1 — report open PRs labelled as pending backports. + * + * @param {Array} openPrs all open PRs + */ +function reportPendingBackports(openPrs) { + print.section('Pending Backports') + + const backports = openPrs.filter((pr) => (pr.labels || []).some((l) => l.name === 'backport-request')) + + if (backports.length === 0) { + print.ok('No pending backports') + } else { + print.warn(`${backports.length} pending backport(s):`) + for (const pr of backports) { + print.log(` • #${pr.number} [${pr.base.ref}]: ${pr.title}`) + } + } +} + +/** + * Section 2 — report open milestones and their high-priority issues. + * + * @param {boolean} verbose whether to list every open issue, not just high-priority + * @return {Promise>} the open milestones, reused by later sections + */ +async function reportMilestonesStatus(verbose) { + print.section('Milestones Status') + + const openMilestones = await ghFetchAll(`/repos/${REPO}/milestones?state=open&per_page=100`) + + if (openMilestones.length === 0) { + print.note('No open milestones found') + return openMilestones + } + + for (const milestone of openMilestones) { + const title = milestone.title + // Milestone already carries the open-issue count; only fetch the + // issue list when there's actually something open. + const openIssues = milestone.open_issues + + if (openIssues === 0) { + print.item(`${title} (ready)`) + continue + } + + // Issues API mixes in PRs; filter those out below. + let path = `/repos/${REPO}/issues?milestone=${milestone.number}&state=open&per_page=100` + if (!verbose) { + // Only high-priority ones get printed, so filter server-side. + path += '&labels=high' + } + const issues = (await ghFetchAll(path)).filter((i) => !i.pull_request) + const highIssues = verbose ? issues.filter((i) => (i.labels || []).some((l) => l.name === 'high')) : issues + + if (highIssues.length > 0) { + print.item(`${title}: ${COLOR.YELLOW}${openIssues} open issue(s)${COLOR.NC} (${COLOR.RED}${highIssues.length} high-priority${COLOR.NC})`) + for (const i of highIssues) { + print.log(` ${COLOR.RED}#${i.number}: ${i.title}${COLOR.NC}`) + } + } else { + print.item(`${title}: ${COLOR.YELLOW}${openIssues} open issue(s)${COLOR.NC}`) + } + if (verbose) { + for (const i of issues) { + print.log(`#${i.number}: ${i.title}`) + } + } + } + + return openMilestones +} + +/** + * Section 3 — report open PRs per stable branch, reusing section 1's list. + * + * @param {string[]} stableBranches the branches to report on + * @param {Set} existingStableBranches which of those exist on origin + * @param {Array} openPrs all open PRs + */ +function reportOpenPullRequests(stableBranches, existingStableBranches, openPrs) { + print.section('Open Pull Requests') + + if (stableBranches.length === 0) { + print.note('No stable branches to check') + return + } + + for (const branch of stableBranches) { + if (!existingStableBranches.has(branch)) { + print.warn(`Branch '${branch}' not found in origin`) + continue + } + + const prs = openPrs.filter((pr) => pr.base.ref === branch) + if (prs.length === 0) { + print.item(`${branch}: no open PRs`) + } else { + print.item(`${branch}: ${COLOR.YELLOW}${prs.length} open PR(s)${COLOR.NC}`) + for (const pr of prs) { + print.log(` #${pr.number}: ${pr.title}`) + } + } + } +} + +/** + * Section 4 — report whether each stable branch has dependabot patch-update + * coverage. + * + * @param {string[]} stableBranches the branches to report on + * @return {string|null} the raw .github/dependabot.yml content, or null if missing + */ +function reportDependabotCoverage(stableBranches) { + print.section('Dependabot Coverage') + + const dependabotContent = existsSync('.github/dependabot.yml') ? readFileSync('.github/dependabot.yml', 'utf-8') : null + + if (stableBranches.length === 0) { + print.note('No stable branches to check') + } else if (dependabotContent === null) { + print.warn('.github/dependabot.yml not found') + } else { + for (const branch of stableBranches) { + if (dependabotContent.includes(`target-branch: ${branch}`)) { + print.ok(`${branch}: patch updates configured`) + } else { + print.warn(`${branch}: missing from .github/dependabot.yml — add composer and npm patch update entries`) + } + } + } + + return dependabotContent +} + +/** + * Section 5 — checks for a branch preparing the first RC of a major release + * (minor=0, patch=0, no RC tags yet): manual checklist items plus + * dependabot/migration-diff status. + * + * @param {string} branch the stable branch to check + * @param {string|null} dependabotContent the raw .github/dependabot.yml content + * @param {() => void} announceSection prints the section header, once, right before the first qualifying branch + */ +function checkFirstRcOfMajorRelease(branch, dependabotContent, announceSection) { + const branchVersion = readBranchInfoVersion(branch) + if (!branchVersion) { + return + } + + const [talkMajor, talkMinor, patchRaw] = branchVersion.split('.') + const talkPatch = patchRaw?.match(/^\d+/)?.[0] ?? '' + + if (talkMinor !== '0' || talkPatch !== '0') { + return + } + + const rcTags = tryRead('git', ['ls-remote', '--tags', 'origin', `refs/tags/v${talkMajor}.0.0-rc.*`]) || '' + const existingRcs = rcTags.split('\n').filter(Boolean).length + if (existingRcs > 0) { + return + } + + announceSection() + print.item(`${branch} at v${branchVersion} — preparing first RC of Talk ${talkMajor}`) + + print.warn(` Manual: Create 'New in Talk ${talkMajor}' entries in the 'Talk updates ✅' conversation`) + print.warn(' Manual: Review GDPR document for any new database tables/columns') + print.note(' Hint: Run \'make appstore\' to verify packaging exclude list in Makefile is up to date') + + // Dependabot check for this branch (template item: "patch updates to the stable branch") + if (dependabotContent !== null) { + if (dependabotContent.includes(`target-branch: ${branch}`)) { + print.ok(` dependabot.yml: patch updates configured for ${branch}`) + } else { + print.warn(` dependabot.yml: ${branch} is missing — add composer and npm patch update entries`) + } + } + + // New DB migrations since last tag (to assist the GDPR check) + const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0', `origin/${branch}`]) + if (lastTag) { + const diff = tryRead('git', ['diff', '--name-only', `${lastTag}..origin/${branch}`, '--', 'lib/Migration/']) || '' + const newMigrations = diff.split('\n').filter((f) => f.endsWith('.php')) + if (newMigrations.length === 0) { + print.ok(` No new DB migration files since ${lastTag}`) + } else { + print.warn(` New DB migration files since ${lastTag} (verify GDPR document):`) + for (const f of newMigrations) { + print.log(` • ${f}`) + } + } + } else { + print.note(' No previous tag found — check DB migrations manually') + } +} + +/** + * Section 5 — run the first-RC checks for every branch, printing the section + * header only once and only if a branch triggers it. + * + * @param {string[]} stableBranches the branches to check + * @param {Set} existingStableBranches which of those exist on origin + * @param {string|null} dependabotContent the raw .github/dependabot.yml content + */ +function reportFirstRcChecks(stableBranches, existingStableBranches, dependabotContent) { + // Fires when minor=0, patch=0, and no RC tags exist yet — the prep phase + // before rc.1 is tagged. + let firstRcFound = false + const announceSection = () => { + if (!firstRcFound) { + print.section('First RC of Major Release — Additional Checks') + firstRcFound = true + } + } + + for (const branch of stableBranches) { + if (!existingStableBranches.has(branch)) { + continue + } + checkFirstRcOfMajorRelease(branch, dependabotContent, announceSection) + } +} + +/** Section 7 — warn about any uncommitted local changes. */ +function reportRepositoryStatus() { + print.section('Repository Status') + + const status = run('git', ['status', '--porcelain'], { capture: true }) + if (status) { + print.warn('Uncommitted changes detected:') + for (const line of status.split('\n')) { + print.log(` ${line}`) + } + } else { + print.ok('Working directory is clean') + } +} + +/** Print the follow-up steps once the report is done. */ +function printNextSteps() { + print.header('Next Steps') + + print.log() + print.log(`${COLOR.CYAN}Address any blockers above, then:${COLOR.NC}`) + print.log(' 1. Prepare changelog: make prepare-changelog') + print.log(' Review and adjust docs/changelogs/*.md, then push and open a PR') + print.log(' 2. Follow https://github.com/nextcloud/spreed/issues/5879 template') +} + +/** Run the release-preparation report. */ +async function main() { + const { verbose, stableBranches } = parseArguments() + + print.header('Nextcloud Spreed Release Preparation Report') + + checkPreflight() + printScope(stableBranches) + + const { existingStableBranches } = gitSetup(stableBranches) + + reportVersionInfo() + + const openPrs = await fetchOpenPrs() + reportPendingBackports(openPrs) + + await reportMilestonesStatus(verbose) + reportOpenPullRequests(stableBranches, existingStableBranches, openPrs) + const dependabotContent = reportDependabotCoverage(stableBranches) + reportFirstRcChecks(stableBranches, existingStableBranches, dependabotContent) + + reportRepositoryStatus() + printNextSteps() +} + +main().catch((err) => { + print.err(err.message) + process.exit(1) +})