# -*- mode: sh; sh-shell: bash -*-
# vim: set ft=bash:
# shellcheck shell=bash
# shellcheck disable=2016
### Rules for software releases
###
### The 'release' module implements a (opinionated) release workflow. Making releases starts
### on a git toplevel in a development or main branch, a devel branch does not need to be
### clean. For each release a new branch/worktree based on the last commit is created. All
### release work will be done in that branch/worktree. This can be a interactive process, when
### release_tests fails one can correct things and commit them until the tests pass. The
### actual release procedure then evaluates 'release_prepare', 'release_commit', 'release_tag'
### and 'release_publish'. Finally 'release_postprocess' and 'release_cleanup' are evaluated.
###
### Any non published release can be aborted/deleted.
###
### This only defines the bare workflow the actual implementation hooks into the
### 'release_rules'.
###
### We still make a few fixed assumptions:
### - Versioning is based on semver
### - Releases are called 'release'
### - The main branch can be named 'main' or 'master'
### - Development branches matching 'devel' 'devel-*' or 'devel/*'
###
### The worktree tracks its state in a local git config bar.release.state:
### - start:
### The worktree is created but not tested yet.
### - tested:
### All test passed.
### - changelog:
### generated the changelog and committed it.
### - ready:
### The worktree/branch is ready for publishing
### - published:
### uploads succeeded.
### - done:
### The release finished.
###
### Further following git config variables are set:
### - bar.release.startbranch:
### Branch this release initiated from (usually main or devel).
### - bar.release.version:
### The (semver) version of this release.
###
### Release Configuration and Workflow
###
### Every release starts in its own git worktree. All testing, and fixes necessary for the
### release are made in that worktree.
###
### Configuration of release workflows is done by extending the rules from 'release_rules'.
### Many other '*_rules' modules already hook into these rules and do the 'right thing'.
### The parts that can configured in Barf are:
### - release_prepare:
### Any extra preparation for releases. 'cargo_rules' already bumps the version.
### - release_tests:
### Whatever tests are necessary for making a software release. 'lints' and 'tests' are
### already defined dependencies. Anything more needs to be added in Barf.
### - release_preprocess:
### Any final work that needs to be done before publishing. This should not change the
### project files anymore. This is the place to do git tags, merge/fast-forward branches
### or switch over to other worktrees. For convenience the 'release_as_*' rules exist to
### do this work.
### - release_publish:
### The actual publishing part. May need access to the internet.
### The 'git_rules' and 'cargo_rules' already hook into this.
### - release_postprocess:
### Any work to be done after the publishing. Like rebase work/devel branches on the
### release and so on.
### - release_cleanup:
##- Final clean up work to remove artifacts not needed after the release.
# ## rule --disjunct release_generate_version_hook: -- false
require git_lib semver_lib
# PLANNED: basedir for release worktrees etc
export RELEASE_BRANCH_DELIMITER="${RELEASE_BRANCH_DELIMITER:--}" ## The prefix for release branches, should be "-" or "/"
function release_abort # <release> - A release that is not published yet can be aborted and deleted
{
declare -l worktree_main
declare -Al worktree_dirs
# shellcheck disable=2034
declare -Al worktree_branches
git_parse_worktrees worktree_main worktree_dirs worktree_branches
local release="${1:-}"
if [[ -z "$release" ]]; then
# shellcheck disable=2155
local branch="$(git_branch_name)"
if [[ "$branch" = "release$RELEASE_BRANCH_DELIMITER"* ]]; then
release="${branch#release"$RELEASE_BRANCH_DELIMITER"}"
else
error "no release given and not in a release branch"
return 1
fi
fi
semver_validate "$release" || {
error "not a valid semver: $release"
return 1
}
# shellcheck disable=2155
local worktree_dir="${worktree_dirs[release-$release]}"
# shellcheck disable=2155
local state="$(git --git-dir="$worktree_dir/.git" config --get bar.release.state)"
# can only abort started to ready states. Any further state is already committed/merged/published.
if [[ "$state" =~ ^start|tested|changelog|ready$ ]]; then
info "aborting $release"
cd "$worktree_main" || return 1
git worktree remove -f "$worktree_dir"
git branch -D "release-$release"
git tag -d "v$release"
else
error "can't abort $release because state is $state"
return 1
fi
}
# only called from main or devel branch see below
function release_start
{
local startbranch
startbranch="$(git_branch_name)"
local version="${1:-auto}"
[[ "$version" != [0-9]* ]] && version="$(rule_eval release_generate_version "$version")"
semver_validate "$version" || {
error "not a valid semver: ${version:-<no version given>}"
return 1
}
(
release_enter_worktree "$version" || {
error "failed to create worktree for $version"
return 1
}
git config set --worktree bar.release.startbranch "$startbranch"
git config set --worktree bar.release.version "$version"
release_state_set start
rule_eval release "$version"
)
}
function release_version
{
memo git config get --worktree bar.release.version
}
## checks whenever the current worktrees release is a patch release
function release_is_patch
{
memo semver_is_patch "$(release_version)"
}
# create branch/worktree for the release
function release_enter_worktree
{
trace "$*"
local version="$1"
local git_main_worktree
git_parse_worktrees git_main_worktree
# we need worktree configs enabled
git config extensions.worktreeConfig true
git worktree add -b "release$RELEASE_BRANCH_DELIMITER$version" "$git_main_worktree/release-$version" "$startbranch" || {
error "failed to create worktree"
return 1
}
# PLANNED: support for worktree subdirs release/$version kept distinct from branch names
cd "$git_main_worktree/release-$version" || {
error "failed to cd into worktree"
return 1
}
}
# Eventually we may relax this rule since one may want to make releases from subprojects
## Makes a software release. Takes an optional version number starting with a digit or a
## name for the generating the version by 'release_generate_version_hook' as argument.
## When no argument is provided it defaults to 'auto'.
rule release: is_git_toplevel
# devel branch, can be dirty, but must be an ancestor of main because later we want to merge back
rule release: --conclusive is_git_devel_branch? '!git_is_ancestor main master' -- '
error "the devel branch must be an ancestor of the main branch"
false
'
rule release: --conclusive is_git_devel_branch? -- '
release_start "${RULE_ARGS[@]}"
'
# main branch must be clean
rule release: --conclusive is_git_main_branch? git_is_clean -- '
release_start "${RULE_ARGS[@]}"
'
# same for bugfix branches branch must be clean
rule release: --conclusive is_git_bugfix_branch? git_is_clean -- '
release_start "${RULE_ARGS[@]}"
'
rule release: --conclusive is_git_hotfix_branch? git_is_clean -- '
release_start "${RULE_ARGS[@]}"
'
rule release_pending: is_git_release_branch 'release_state_matches !done'
# Safety check, running in release worktree
rule release: !release_pending -- '
error "no release pending"
false
'
# The release worktree must be clean
rule release: !git_is_clean -- '
error "uncommited changes exists"
false
'
# We can't proceed on a published release
rule release: 'release_state_matches? published done' -- '
error "release already published or done"
false
'
# The state machine doing the actual (restartable) release:
rule release: 'release_state_matches? start' release_prepare release_tests 'release_state_set tested'
rule release: 'release_state_matches? tested' release_changelog git_is_clean 'release_state_set changelog'
rule release: 'release_state_matches? changelog' release_preprocess 'release_state_set ready'
rule release: 'release_state_matches? ready' release_publish 'release_state_set published'
rule release: 'release_state_matches? published' release_postprocess 'release_state_set done'
rule release: 'release_state_matches? done' release_cleanup~
function release_state_set # <state> - sets the release state
{
git config set --worktree bar.release.state "$1"
}
function release_state_get # <state> - gets the release state
{
git config get --worktree bar.release.state
}
function release_state_matches ## [[!]statematch] - Check if the current state matches (does not match with '!').
{
local state
for state in "$@"; do
if [[ "${state:0:1}" != "!" ]]; then
# shellcheck disable=2053
[[ "$(release_state_get)" = $state ]] && return 0
else
state="${state:1}"
# shellcheck disable=2053
[[ "$(release_state_get)" != $state ]] && return 0
fi
done
return 1
}
function release_into ## <target_version> - switch over to another existing release worktree, reset it to the current release state
{
## We prepare releases in dedicated worktrees. This would leave a lot worktrees behind.
## This provides the facility to coalesce worktrees to keep only major, majorpre1x, or majorminor versions.
local release
release=$(release_version)
local state
state="$(release_state_get)"
local startbranch
startbranch="$(git config get --worktree bar.release.startbranch)"
local target_version="$1"
declare -l worktree_main
declare -Al worktree_dirs
# shellcheck disable=2034
declare -Al worktree_branches
git_parse_worktrees worktree_main worktree_dirs worktree_branches
local target_worktree_dir="${worktree_dirs[release$RELEASE_BRANCH_DELIMITER$target_version]}"
[[ -d "$target_worktree_dir" ]] || {
error "target worktree for branch 'release$RELEASE_BRANCH_DELIMITER$target_version' does not exist"
return 1
}
git_is_ancestor "release$RELEASE_BRANCH_DELIMITER$release" || {
error "release$RELEASE_BRANCH_DELIMITER$target_version is not a ancestor of release$RELEASE_BRANCH_DELIMITER$release"
return 1
}
# switch over
cd "$target_worktree_dir" || return 1
git reset --hard "release$RELEASE_BRANCH_DELIMITER$release"
release_state_set "$state"
git config set --worktree bar.release.startbranch "$startbranch"
git config set --worktree bar.release.version "$release"
# can remove the release-x.y.z worktree and branch now
git worktree remove "${worktree_dirs[release$RELEASE_BRANCH_DELIMITER$release]}"
git branch -d "release$RELEASE_BRANCH_DELIMITER$release"
}
## Transfer over to major version, for workflows that keep 'release-x' worktrees and branches
function release_into_major
{
release_into "$(semver_major "$(release_version)")"
}
## Transfer over to major version with 0.x major semantic, for workflows that keep 'release-0.x' and 'release-x' worktrees and branches
function release_into_majorpre1x
{
release_into "$(semver_majorpre1x "$(release_version)")"
}
## Transfer over to major.minor version, for workflows that keep 'release-x.y' worktrees and branches
function release_into_majorminor
{
release_into "$(semver_majorminor "$(release_version)")"
}
function release_worktree_rename ## <version> - rename worktree and cd into that
{
# shellcheck disable=2155
local current="$(git rev-parse --show-toplevel)"
# safety check
[[ "${current##*/}" = "release-"* ]] || {
error "not in a relelease-* worktree"
return 1
}
local target="${current%/*}/release-$1"
git worktree move "$current" "$target"
cd target || return 1
}
## Rename worktree to'release-x' with major version only.
function release_worktree_rename_major
{
release_worktree_rename "$(semver_major "$(release_version)")"
}
## Rename worktree to'release-x[.y]' with 0.x major semantic.
function release_worktree_rename_majorpre1x
{
release_worktree_rename "$(semver_majorpre1x "$(release_version)")"
}
## Rename worktree to 'release-x.y' with major.minor.
function release_worktree_rename_majorminor
{
release_worktree_rename "$(semver_majorminor "$(release_version)")"
}
## To be used from release-preprocess. Creates or resets the release worktree to release.x major.
rule release_as_major: !release_is_patch release_worktree_rename_major
rule release_as_major: release_is_patch? release_into_major
## To be used from release-preprocess. Creates or resets the release worktree to release.x[.y] major with 0.x major semantic.
rule release_as_majorpre1x: !release_is_patch release_worktree_rename_majorpre1x
rule release_as_majorpre1x: release_is_patch? release_into_majorpre1x
## To be used from release-preprocess. Creates or resets the release worktree to release.x.y major.minor.
rule release_as_majorminor: !release_is_patch release_worktree_rename_majorminor
rule release_as_majorminor: release_is_patch? release_into_majorminor