-
Notifications
You must be signed in to change notification settings - Fork 487
[DRAFT] Add script for cutting releases #15558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
shonfeder
wants to merge
1
commit into
main
Choose a base branch
from
shonfeder/release-cut-script
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+251
−6
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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=<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" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my attempt to test this locally against my personal forks today, I found that this is actually insufficient for testing locally, because despite having
export DUNE_RELEASE_DEV_REPO=https://github.com/shonfeder/dune.git,dune-releasestill tried to push the release to the main repo, based on the opam file configuration. I am not sure yet whether this was due to a configuration error on my part, or some unexpected behavior fromdune-release.