diff --git a/doc/changes/scripts/build_changelog.sh b/doc/changes/scripts/build_changelog.sh index 1d391cde774..de46bff204e 100755 --- a/doc/changes/scripts/build_changelog.sh +++ b/doc/changes/scripts/build_changelog.sh @@ -6,13 +6,18 @@ # Author(s): The Dune team # Date: 2025-09-29 # -# Usage: $ ./build_changelog.sh -# where X.Y.Z is the version of Dune +# Usage: $ [KEEP_FRAGMENTS=true] ./build_changelog.sh +# where +# - X.Y.Z is the version of Dune (an optional ~alphaN suffix is allowed) +# - KEEP_FRAGMENTS, when 'true', regenerates the changelog section without +# deleting the change fragments, so successive prereleases of the same +# version can be regenerated in place. Defaults to deleting them. set -euo pipefail # Variables version="$1" +keep_fragments="${KEEP_FRAGMENTS:-false}" output=".changelog.new" doc_dir="./doc/changes" @@ -70,7 +75,10 @@ append_files_in_dir_if_not_empty() { # Remove any empty lines from each change entry and add them to the output grep -v '^$' "$file" >> "$output" add_newline - rm "$file" + # Consume the fragment unless we are keeping it for a later release + if [ "$keep_fragments" != "true" ]; then + rm "$file" + fi done } diff --git a/doc/dev/releases/README.md b/doc/dev/releases/README.md index bcb42488410..faf27c9c6ae 100644 --- a/doc/dev/releases/README.md +++ b/doc/dev/releases/README.md @@ -1,4 +1,12 @@ # Documentation and scripts for managing releases +See [./process.md](./process.md) for the end-to-end release process. The scripts +below automate its mechanical steps, listed in the order they are typically used: + +- [./release-init.sh](./release-init.sh) start a release: create the tracking + issue, cut the release candidate branch, and open the draft release PR. - [./backport.sh](./backport.sh) backport the commits in a PR merged or squashed - (but NOT rebased) into `main` into a release candidate branch. + (but NOT rebased) into `main` into a release candidate branch. +- [./release-cut.sh](./release-cut.sh) cut a (pre)release from a release + candidate branch: prepare the changelog, commit and push it, and run + `dune-release` through its publication flow. diff --git a/doc/dev/releases/process.md b/doc/dev/releases/process.md index 3596259f33c..06b07d6d39d 100644 --- a/doc/dev/releases/process.md +++ b/doc/dev/releases/process.md @@ -51,7 +51,8 @@ stateDiagram-v2 - (for N=0) create `x.y` branch - Prepare alpha release: - cherry-pick extra commits from `main` (if any) - - prepare changelog (ensure version is `x.y.0~alphaN`) + - prepare changelog: regenerate the `x.y.0~alphaN` section from the change + fragments in `doc/changes`, keeping the fragments until the final release - `make opam-release` - mark opam-repo PR as draft - Wait for `opam-repo-ci` @@ -65,7 +66,8 @@ stateDiagram-v2 - Release time: - check versioned behaviors are relative to x.y - - On release branch, prepare changelog (merge alpha entries, set header with version) + - On release branch, prepare changelog: regenerate the section from the change + fragments, which are consumed (removed) by this final release - Open a PR `prepare-x.y.0` - Self-merge - `make opam-release` from updated main diff --git a/doc/dev/releases/release-cut.sh b/doc/dev/releases/release-cut.sh new file mode 100755 index 00000000000..967f4a799b3 --- /dev/null +++ b/doc/dev/releases/release-cut.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash + +# Script name: release-cut.sh +# Description: This script cuts a release from a release candidate branch of dune +# Author(s): The Dune team +# Date: 2026-05-01 +# +# Usage: +# +# $ RELEASE_KIND=(release|prerelease) [DRY_RUN=true] [DUNE_REMOTE=] ./release-cut.sh +# +# where +# +# - RELEASE_KIND indicates whether the release should be a prerelease (an alpha) +# or a full release. +# - The optional DRY_RUN, when set to 'true', prints the mutating commands +# instead of running them, so the release flow can be previewed safely. +# - The optional DUNE_REMOTE can be used to set the name of the git remote to +# fetch tags from and push the release branch to. It defaults to 'git config +# remote.pushdefault' if set or finally to 'origin' if not. +# - NB. To stage a release against forks, dune-release reads the following from +# the environment; they pass through 'make opam-release' unchanged: +# - DUNE_RELEASE_DEV_REPO the dune fork the release/tag/tarball goes to +# (a plain git URL, e.g. https://..., not git+https) +# - DUNE_RELEASE_OPAM_REPO the opam-repository the package PR is opened into +# (owner/repo form) +# - DUNE_RELEASE_REMOTE_REPO your fork of opam-repository to push the PR from +# (a git URL, e.g. git@github.com:you/opam-repository.git) +# - DUNE_RELEASE_LOCAL_REPO path to a local clone of that opam-repository fork +# Set DUNE_REMOTE to the matching dune fork remote so the release branch and +# changelog commit are pushed there too. +# +# E.g., +# +# $ RELEASE_KIND=prerelease ./release-cut.sh +# +# and, to stage the whole flow against personal forks, +# +# $ RELEASE_KIND=prerelease \ +# DUNE_REMOTE=my-fork \ +# DUNE_RELEASE_DEV_REPO=https://github.com/me/dune.git \ +# DUNE_RELEASE_OPAM_REPO=me/opam-repository \ +# ./release-cut.sh +# +# The script will prepare the change log, commit and push updates, and run dune-release +# thru its entire release and publication flow. + +set -e +set -o pipefail + +function err () { + local msg=$1 + echo >&2 "error: ${msg}" + exit 1 +} + +# run command if DRY_RUN is false or not set, else just print the command +function run_cmd () { + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo "DRY RUN: $*" + else + "$@" + fi +} + +# Prompt for confirmation before running the irreversible release steps +function confirm () { + local release_version="$1" + read \ + -p "About to cut ${RELEASE_KIND} ${release_version} on branch '${branch}', push to '${DUNE_REMOTE}', and publish via dune-release. Continue? (y/Y) " \ + -n 1 -r + echo # Print a newline since -n 1 suppresses the newline after input + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Proceeding..." + else + echo "Aborted." + exit 1 + fi +} + +# Validate and prepare input variables +[ ! -z "${RELEASE_KIND}" ] || err "variable RELEASE_KIND is not set" +# Get the remote configured by envvar, or via the git config remote.pushDefault, +DUNE_REMOTE=${DUNE_REMOTE:-$(git config remote.pushdefault || echo "")} +# Finally fallback to 'origin' if the remote isn't configured +DUNE_REMOTE=${DUNE_REMOTE:-"origin"} + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ROOT_DIR=$(realpath "${SCRIPT_DIR}/../../..") + +# Check for required utilities +command -v git >/dev/null 2>&1 || err "script requires git" +command -v dune-release >/dev/null 2>&1 || err "script requires dune-release" + +# All variables should be set from this point on +set -u + +# Parser a version from a git tag and produces an incremented prerelease version +function increment_prerelease () { + local v="$1" + if [[ "$v" =~ ^(.*)_alpha([0-9]+)$ ]]; then + base="${BASH_REMATCH[1]}" + n="${BASH_REMATCH[2]}" + res="${base}~alpha$((n + 1))" + else + res="${v}~alpha0" + fi + echo "$res" +} +# increment_prerelease "1.1.2" # 1.1.2~alpha0 +# increment_prerelease "1.1.2_alpha0" # 1.1.2~alpha1 +# increment_prerelease "1.1.2_alpha5" # 1.1.2~alpha6 + +# extract just the version from a string with a semantic version as prefix +function extract_version_prefix () { + local input="$1" + local err_msg=${2:-"extract_version_prefix: invalid version string: $1"} + if [[ "${input}" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-rc|[~_]alpha[0-9]+)?$ ]]; then + echo "${BASH_REMATCH[1]}" + else + err "${err_msg}" + fi +} +# extract_version_prefix "1.1.1-rc" # 1.1.1 +# extract_version_prefix "1.2.1~alpha10" # 1.2.1 +# extract_version_prefix "1.2.1-alpha10" # default error message +# extract_version_prefix "1.2.1-alpha10" "custom error" # custom error message + +git fetch --tags "${DUNE_REMOTE}" +branch=$(git branch --show-current) +version=$(extract_version_prefix "${branch}" \ + "must be run from a release candidate branch matching 'x.y.z-rc' but run from ${branch}") +# versionsort.suffix="_alpha" ensures _alpha suffixes are sorted as preceding the actual version release +last_version=$(git -c versionsort.suffix="_alpha" tag --list "${version}" "${version}_alpha*" --sort=-version:refname | head -n 1 ) +if [[ -n "${last_version}" ]]; then + last_version_prefix=$(extract_version_prefix "${last_version}") +else + last_version_prefix="" # no prior tags for this version +fi + +# Drop the changelog section for the in-progress version, if the changelog +# already opens with one (e.g. from a previous alpha), so the regenerated +# section replaces it instead of stacking a duplicate. The change fragments +# remain the source of truth; this only removes the stale rendering. +function strip_in_progress_section () { + local changes="${ROOT_DIR}/CHANGES.md" tmp + tmp=$(mktemp "${changes}.XXXXXX") || err "could not create a temporary file" + if awk -v vp="${version}" ' + # Return the X.Y.Z of a version header like "3.24.0 (2026-06-21)", + # or "" if the line is not a version header. + # The second parameter, `v`, is a local variable, not an input argument. + function header_version(line, v) { + if (line !~ /^[0-9]+\.[0-9]+\.[0-9]+[^ ]* \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/) { + return "" + } else { + v = line + sub(/ .*/, "", v) # drop " (date)" -> "3.24.0" or "3.24.0~alpha1" + sub(/[~_-].*/, "", v) # drop the suffix -> "3.24.0" + return v + } + } + { + v = header_version($0) + if (v != "") { + # A version header starts a section. Skip exactly one section: + # the first (top-most), and only if it is the in-progress version. + section++ + skip = (section == 1 && v == vp) + } + if (!skip) print + } + ' "${changes}" > "${tmp}"; then + mv "${tmp}" "${changes}" + else + rm -f "${tmp}" + err "failed to update ${changes}" + fi +} + +# Regenerate the in-progress changelog section from the change fragments in +# doc/changes. Those fragments are the single source of truth: they are consumed +# (deleted) only for a full release, so successive prereleases regenerate the +# section in place from the accumulating fragments. Any existing section for the +# in-progress version is stripped first so the regenerated one replaces it. +function update_changelog () { + local release_version="$1" + local keep_fragments=true + if [[ "${RELEASE_KIND}" == "release" ]]; then + keep_fragments=false + fi + run_cmd strip_in_progress_section + run_cmd env "KEEP_FRAGMENTS=${keep_fragments}" \ + "${ROOT_DIR}/doc/changes/scripts/build_changelog.sh" "${release_version}" +} + +function pre_release_version () { + if [[ "${version}" == "${last_version}" ]] + then + err "cannot cut a pre-release on branch ${branch} because the last version is ${version}" + elif [[ "${version}" == "${last_version_prefix}" ]] + then + increment_prerelease "${last_version}" + else + increment_prerelease "${version}" + fi +} + +function release () { + local release_version="$1" + if [[ "${DRY_RUN:-false}" != "true" ]]; then + confirm "${release_version}" + fi + run_cmd git pull --ff-only "${DUNE_REMOTE}" "${branch}" + update_changelog "${release_version}" + run_cmd git add "${ROOT_DIR}/doc/changes" "${ROOT_DIR}/CHANGES.md" + run_cmd git commit -s -m "[${release_version}] prepare release" + run_cmd git push -u "${DUNE_REMOTE}" "${branch}" + run_cmd make RELEASE_KIND="$RELEASE_KIND" DUNE_RELEASE_YES=true opam-release +} + +case "${RELEASE_KIND}" in + "prerelease") release_version=$(pre_release_version) ;; + "release") release_version="${version}" ;; + *) err "RELEASE_KIND must be 'prerelease' or 'release'" ;; +esac + +release "$release_version"