diff --git a/release/README.md b/release/README.md index b4f5820..c6408c3 100644 --- a/release/README.md +++ b/release/README.md @@ -42,7 +42,8 @@ These steps are repeated for each release tag i.e. for both release-candidates a # repeat the above with e.g. the tag 25.3.0 once a release candidate is accepted. # continue with the demos ... -# create and push the release branch +# create and push the release branch (demos are intentionally branch-only: +# no RC branches, no tagging, no post-release steps) ./release/create-release-branch.sh -b 25.3 -w demos # Only add the -p flag after testing locally first # finally - i.e. when a release candidate has been accepted and the actual release has been tagged - patch the changelog file in the main branch @@ -118,6 +119,7 @@ A set of scripts that automates some release steps. The release process has mult > - Steps 2-4 will check out the release branch (or clone it if does exist locally) and so can be run independently of each other. > - Any changes should be done manually between steps 2 and 3 i.e. by making changes in the PR branch and/or cherry-picking commits from main. +> - Demos only require step 1 (branch creation). They are intentionally not tagged or given release candidates. ## Install requirements diff --git a/release/common.sh b/release/common.sh new file mode 100644 index 0000000..6b6e662 --- /dev/null +++ b/release/common.sh @@ -0,0 +1,554 @@ +#!/usr/bin/env bash +# +# Common functions shared across release scripts. +# This file is meant to be sourced, not executed directly. +# +# Usage (from any release script): +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$SCRIPT_DIR/common.sh" +# + +# Guard against direct execution +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + >&2 echo "Error: common.sh should be sourced, not executed directly." + exit 1 +fi + +# Iterate over operators from config.yaml, calling a function for each. +# Requires $INITIAL_DIR to be set (for reading config.yaml). +# +# Usage: +# for_each_operator my_function +# for_each_operator my_function "extra_arg" +# +# The function receives the operator name as its first argument, +# followed by any extra arguments passed to for_each_operator. +for_each_operator() { + local func="$1" + shift + + while IFS="" read -r operator || [ -n "$operator" ]; do + "$func" "$operator" "$@" + done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) +} + +# Clone a repo from stackabletech if it doesn't already exist locally. +# Must be called from the directory where the clone should be created. +# +# Usage: +# ensure_clone "airflow-operator" # clone default branch +# ensure_clone "airflow-operator" "--branch main" # clone specific branch +ensure_clone() { + local repo="$1" + local clone_args="${2:-}" + + if [ ! -d "$repo" ]; then + echo "Cloning $repo..." + # shellcheck disable=SC2086 + git clone $clone_args "git@github.com:stackabletech/${repo}.git" "$repo" + fi +} + +# Ensure the temp release folder exists. +# Requires $TEMP_RELEASE_FOLDER to be set (via derive_tag_vars or derive_branch_vars). +ensure_temp_folder() { + if [ ! -d "$TEMP_RELEASE_FOLDER" ]; then + echo "Creating folder: [$TEMP_RELEASE_FOLDER]" + mkdir -p "$TEMP_RELEASE_FOLDER" + fi +} + +# Update a CHANGELOG.md file with a release tag entry. +# Idempotent: skips if the tag is already present in the changelog. +# +# Usage: +# update_changelog "path/to/CHANGELOG.md" "$RELEASE_TAG" +update_changelog() { + local changelog="$1" + local tag="$2" + + validate_tag "$tag" + + if grep -qF "## [$tag]" "$changelog"; then + echo "Changelog already contains $tag, skipping" + return + fi + + local today + today=$(date +'%Y-%m-%d') + sed -i "s/^.*unreleased.*/## [Unreleased]\n\n## [$tag] - $today/I" "$changelog" +} + +# Verify release transformations are correct before committing. +# Checks whichever files exist, so it is safe to call for both operators and products. +# Returns non-zero if any check fails. +# +# Usage: +# verify_release "$dir" "$RELEASE_TAG" "$RELEASE_BASE" +verify_release() { + local dir="$1" + local tag="$2" + local release_base="$3" + local errors=0 + + echo "Verifying release transformations in $(basename "$dir")..." + + # Cargo.toml workspace version + if [ -f "$dir/Cargo.toml" ] && grep -q '^\[workspace\.package\]' "$dir/Cargo.toml"; then + local cargo_ver + cargo_ver=$(grep -A 20 '^\[workspace\.package\]' "$dir/Cargo.toml" | grep -m1 '^version' | grep -oP '"\K[^"]+' || true) + if [ -n "$cargo_ver" ] && [ "$cargo_ver" != "$tag" ]; then + >&2 echo " FAIL: Cargo.toml workspace version is '$cargo_ver', expected '$tag'" + errors=$((errors + 1)) + fi + fi + + # Helm Chart.yaml version and appVersion + local chart_yaml + chart_yaml=$(find "$dir/deploy/helm" -maxdepth 2 -name "Chart.yaml" -print -quit 2>/dev/null || true) + if [ -n "$chart_yaml" ]; then + local chart_ver chart_app_ver + chart_ver=$(yq '.version' "$chart_yaml") + chart_app_ver=$(yq '.appVersion' "$chart_yaml") + if [ "$chart_ver" != "$tag" ]; then + >&2 echo " FAIL: Chart.yaml version is '$chart_ver', expected '$tag'" + errors=$((errors + 1)) + fi + if [ "$chart_app_ver" != "$tag" ]; then + >&2 echo " FAIL: Chart.yaml appVersion is '$chart_app_ver', expected '$tag'" + errors=$((errors + 1)) + fi + fi + + # antora.yml: version should be YY.M (release base), prerelease should be false + if [ -f "$dir/docs/antora.yml" ]; then + local antora_ver antora_prerelease + antora_ver=$(yq '.version' "$dir/docs/antora.yml") + antora_prerelease=$(yq '.prerelease' "$dir/docs/antora.yml") + if [ "$antora_ver" != "$release_base" ]; then + >&2 echo " FAIL: antora.yml version is '$antora_ver', expected '$release_base'" + errors=$((errors + 1)) + fi + if [ "$antora_prerelease" != "false" ]; then + >&2 echo " FAIL: antora.yml prerelease is '$antora_prerelease', expected 'false'" + errors=$((errors + 1)) + fi + fi + + # templating_vars.yaml: no *dev* versions remaining + if [ -f "$dir/docs/templating_vars.yaml" ]; then + local dev_entries + dev_entries=$(yq '.versions | to_entries[] | select(.value | test("dev")) | .key' "$dir/docs/templating_vars.yaml" 2>/dev/null || true) + if [ -n "$dev_entries" ]; then + >&2 echo " FAIL: templating_vars.yaml still has dev versions:" + >&2 echo "$dev_entries" | sed 's/^/ /' + errors=$((errors + 1)) + fi + fi + + # tests/release.yaml: all operatorVersion entries should match the tag + if [ -f "$dir/tests/release.yaml" ]; then + local bad_versions + bad_versions=$(yq '.releases.tests.products[] | select(.operatorVersion != "'"$tag"'") | .operatorVersion' "$dir/tests/release.yaml" 2>/dev/null || true) + if [ -n "$bad_versions" ]; then + >&2 echo " FAIL: tests/release.yaml has non-release operatorVersions:" + >&2 echo "$bad_versions" | sort -u | sed 's/^/ /' + errors=$((errors + 1)) + fi + fi + + # CHANGELOG.md should contain the release tag + if [ -f "$dir/CHANGELOG.md" ]; then + if ! grep -qF "## [$tag]" "$dir/CHANGELOG.md"; then + >&2 echo " FAIL: CHANGELOG.md does not contain '## [$tag]'" + errors=$((errors + 1)) + fi + fi + + # No nightly@ references remaining in .adoc files + if [ -d "$dir/docs" ]; then + local nightly_refs + nightly_refs=$(grep -rl 'nightly@' "$dir/docs/" 2>/dev/null || true) + if [ -n "$nightly_refs" ]; then + >&2 echo " FAIL: docs still contain 'nightly@' references:" + >&2 echo "$nightly_refs" | sed 's/^/ /' + errors=$((errors + 1)) + fi + fi + + if [ "$errors" -gt 0 ]; then + >&2 echo "Verification failed with $errors error(s)" + return 1 + fi + echo "Verification passed" +} + +# Strip leading and trailing double quotes from a string. +# +# Usage: +# VAR="$(strip_double_quotes "$VAR")" +strip_double_quotes() { + local val="$1" + val="${val%\"}" + val="${val#\"}" + echo "$val" +} + +# Validate the -w/--what parameter against a set of allowed values. +# +# Usage: +# validate_what "$WHAT" "products" "operators" "all" +# +# Exits with an error if $WHAT is empty or not in the allowed list. +validate_what() { + local what="$1" + shift + local allowed=("$@") + + if [ -z "$what" ]; then + >&2 echo "Error: -w/--what is required. Allowed values: ${allowed[*]}" + exit 1 + fi + + for valid in "${allowed[@]}"; do + if [ "$what" == "$valid" ]; then + return 0 + fi + done + + >&2 echo "Error: Invalid -w/--what value: '$what'. Allowed values: ${allowed[*]}" + exit 1 +} + +# Check common dependencies required by all release scripts: +# - globally configured git user name and email +# - gh (GitHub CLI) authentication +# - yq (YAML processor) +# +# Scripts that need additional dependencies (cargo, jinja2, etc.) +# should call this function first, then check their own extras. +check_common_dependencies() { + if ! git_user=$(git config --global --includes --get user.name) \ + || ! git_email=$(git config --global --includes --get user.email); then + >&2 echo "Error: global git user name/email is not set." + exit 1 + else + echo "global git user: $git_user <$git_email>" + echo "Is this correct? (y/n)" + read -r response + if [[ "$response" == "y" || "$response" == "Y" ]]; then + echo "Proceeding with $git_user <$git_email>" + else + >&2 echo "User not accepted. Exiting." + exit 1 + fi + fi + + # gh authentication: if this fails you will need to e.g. gh auth login + gh auth status + + # yq (YAML processor) - https://github.com/mikefarah/yq + yq --version +} + +# CalVer base pattern: YY.M where month is 1-12 without leading zero. +# Used by both tag and branch validation. +CALVER_BASE='[0-9][0-9]\.([1-9]|1[0-2])' + +# Validate a release tag in CalVer format. +# +# Accepted formats: +# YY.M.P e.g. 26.3.0, 25.11.1 +# YY.M.P-rcN e.g. 26.3.0-rc1, 25.11.1-rc12 +# +# Usage: +# validate_tag "$RELEASE_TAG" # accepts both final and RC tags +# validate_tag --no-rc "$RELEASE_TAG" # rejects RC tags (for post-release) +# +# Exits with an error if the tag doesn't match. +validate_tag() { + local no_rc=false + while [[ "$1" == --* ]]; do + case "$1" in + --no-rc) no_rc=true ;; + *) + >&2 echo "Error: validate_tag: unknown flag '$1'." + exit 1 + ;; + esac + shift + done + local tag="$1" + + if [ -z "$tag" ]; then + >&2 echo "Error: release tag is required." + exit 1 + fi + + if [ "$#" -gt 1 ]; then + >&2 echo "Error: validate_tag: unexpected trailing arguments: ${*:2}" + exit 1 + fi + + local tag_regex="^${CALVER_BASE}\.[0-9]+(-rc[0-9]+)?$" + if [[ ! $tag =~ $tag_regex ]]; then + >&2 echo "Error: tag '$tag' does not match CalVer format (e.g. 26.3.0 or 26.3.0-rc1)." + exit 1 + fi + + if $no_rc && [[ $tag =~ -rc[0-9]+$ ]]; then + >&2 echo "Error: tag '$tag' is a release candidate. This step is only for final releases (e.g. 26.3.0, not 26.3.0-rc1)." + exit 1 + fi +} + +# Validate a release base version in CalVer format (YY.M). +# This is the base from which branch names (release-YY.M) and +# tags (YY.M.P, YY.M.P-rcN) are derived. +# +# Accepted format: +# YY.M e.g. 26.3, 25.11 +# +# Usage: +# validate_release_base_version "$RELEASE_BASE" +# +# Exits with an error if the version doesn't match. +validate_release_base_version() { + local version="$1" + + if [ -z "$version" ]; then + >&2 echo "Error: release version is required." + exit 1 + fi + + local version_regex="^${CALVER_BASE}$" + if [[ ! $version =~ $version_regex ]]; then + >&2 echo "Error: release version '$version' does not match CalVer format (e.g. 26.3 or 25.11)." + exit 1 + fi +} + +# Derive common variables from a release tag. +# Requires $INITIAL_DIR to be set (for reading config.yaml). +# +# Sets: RELEASE_BASE, RELEASE_BRANCH, PR_BRANCH, DOCKER_IMAGES_REPO, TEMP_RELEASE_FOLDER +# +# Usage: +# INITIAL_DIR="$PWD" +# derive_tag_vars "$RELEASE_TAG" +derive_tag_vars() { + local tag="$1" + + RELEASE_BASE="$(cut -d'.' -f1,2 <<< "$tag")" # e.g., 26.3 from 26.3.0-rc1 + RELEASE_BRANCH="release-$RELEASE_BASE" # e.g., release-26.3 + PR_BRANCH="pr-$tag" # e.g., pr-26.3.0-rc1 + DOCKER_IMAGES_REPO=$(yq '... comments="" | .images-repo ' "$INITIAL_DIR"/release/config.yaml) + TEMP_RELEASE_FOLDER="/tmp/stackable-$RELEASE_BRANCH" +} + +# Derive common variables from a release base version (YY.M). +# Requires $INITIAL_DIR to be set (for reading config.yaml). +# +# Sets: RELEASE_BRANCH, DOCKER_IMAGES_REPO, DEMOS_REPO, TEMP_RELEASE_FOLDER +# +# Usage: +# INITIAL_DIR="$PWD" +# derive_branch_vars "$RELEASE_BASE" +derive_branch_vars() { + local release_base="$1" # e.g., 26.3 + + RELEASE_BRANCH="release-$release_base" # e.g., release-26.3 + DOCKER_IMAGES_REPO=$(yq '... comments="" | .images-repo ' "$INITIAL_DIR"/release/config.yaml) + DEMOS_REPO=$(yq '... comments="" | .demos-repo ' "$INITIAL_DIR"/release/config.yaml) + TEMP_RELEASE_FOLDER="/tmp/stackable-$RELEASE_BRANCH" +} + +# Assert that the current directory is inside a git repository. +# Optionally checks that the repository name matches an expected value. +# +# Usage: +# assert_cwd_is_repo # just checks we're in a git repo +# assert_cwd_is_repo "airflow-operator" # also checks the repo name +# +# Exits with an error if the check fails. +assert_cwd_is_repo() { + local expected_name="${1:-}" + + local repo_root + if ! repo_root=$(git rev-parse --show-toplevel 2>/dev/null); then + >&2 echo "Error: current directory ($(pwd)) is not inside a git repository." + exit 1 + fi + + if [ -n "$expected_name" ]; then + local actual_name + actual_name=$(basename "$repo_root") + if [ "$actual_name" != "$expected_name" ]; then + >&2 echo "Error: expected to be in repo '$expected_name', but current repo is '$actual_name'." + exit 1 + fi + fi +} + +# Assert that the current git branch matches the expected name. +# Uses exact string comparison - no regex. +# +# Usage: +# assert_on_branch "release-26.3" +# assert_on_branch "$PR_BRANCH" +# +# Exits with an error if the current branch doesn't match. +assert_on_branch() { + local expected="$1" + + if [ -z "$expected" ]; then + >&2 echo "Error: assert_on_branch: expected branch name is required." + exit 1 + fi + + local actual + actual=$(git branch --show-current) + + if [ "$actual" != "$expected" ]; then + >&2 echo "Error: expected to be on branch '$expected', but currently on '$actual'." + exit 1 + fi +} + +# Assert that a named git remote exists and points to the expected +# repository under github.com/stackabletech. +# +# Usage: +# assert_remote_exists "origin" "airflow-operator" +# +# Handles both SSH (git@github.com:stackabletech/...) and +# HTTPS (https://github.com/stackabletech/...) remote URLs. +# The .git suffix is stripped before comparison. +# +# Exits with an error if the remote doesn't exist or points elsewhere. +assert_remote_exists() { + local remote="$1" + local expected_repo="$2" + + if [ -z "$remote" ] || [ -z "$expected_repo" ]; then + >&2 echo "Error: assert_remote_exists requires a remote name and expected repo name." + exit 1 + fi + + local url + if ! url=$(git remote get-url "$remote" 2>/dev/null); then + >&2 echo "Error: git remote '$remote' does not exist." + exit 1 + fi + + # Strip trailing .git if present + url="${url%.git}" + + # Match both SSH and HTTPS URL formats + local expected_pattern="github\.com[:/]stackabletech/${expected_repo}$" + if [[ ! $url =~ $expected_pattern ]]; then + >&2 echo "Error: remote '$remote' points to '$url', expected github.com/stackabletech/$expected_repo." + exit 1 + fi +} + +# Assert that a tag does NOT exist on the remote. +# Uses `git ls-remote` to check the remote directly without modifying local refs. +# +# Usage: +# assert_tag_not_exists "origin" "$RELEASE_TAG" +# +# Exits with an error if the tag already exists. +assert_tag_not_exists() { + local remote="$1" + local tag="$2" + + if [ -z "$remote" ] || [ -z "$tag" ]; then + >&2 echo "Error: assert_tag_not_exists requires a remote name and tag name." + exit 1 + fi + + if git ls-remote --tags "$remote" "refs/tags/${tag}" | grep -q "refs/tags/${tag}"; then + >&2 echo "Error: tag '$tag' already exists on remote '$remote'!" + exit 1 + fi +} + +# Check whether a remote branch exists. +# Uses `git ls-remote` to check the remote directly without modifying local refs. +# Returns 0 if the branch exists, 1 if not. Does not exit on failure. +# +# Usage: +# if remote_branch_exists "origin" "release-26.3"; then ... +remote_branch_exists() { + local remote="$1" + local branch="$2" + + if [ -z "$remote" ] || [ -z "$branch" ]; then + >&2 echo "Error: remote_branch_exists requires a remote name and branch name." + exit 1 + fi + + git ls-remote --exit-code --heads "$remote" "refs/heads/${branch}" > /dev/null 2>&1 +} + +# Assert that a remote branch exists. +# Exits with an error if the branch is not found on the remote. +# +# Usage: +# assert_remote_branch_exists "origin" "release-26.3" +assert_remote_branch_exists() { + if ! remote_branch_exists "$@"; then + >&2 echo "Error: branch '$2' does not exist on remote '$1'." + exit 1 + fi +} + +# Assert that a remote branch does NOT exist. +# Used to verify we won't collide with an existing branch when creating one. +# +# Usage: +# assert_remote_branch_not_exists "origin" "pr-26.3.0-rc1" +assert_remote_branch_not_exists() { + if remote_branch_exists "$@"; then + >&2 echo "Error: branch '$2' already exists on remote '$1'." + exit 1 + fi +} + +# Assert that the current git working tree has no staged or unstaged +# changes to tracked files. Untracked files trigger a warning and +# a confirmation prompt. +# +# Usage: +# assert_clean_index [context_message] +# +# The optional context_message is included in error output to help +# identify which repo/step failed (e.g., "airflow-operator"). +assert_clean_index() { + local context="${1:-$(basename "$(pwd)")}" + + # Check for staged or unstaged changes to tracked files + if ! git diff-index --quiet HEAD --; then + >&2 echo "Error: dirty git index for $context." + >&2 echo "Staged or unstaged changes to tracked files:" + >&2 git diff-index --name-status HEAD -- + exit 1 + fi + + # Check for untracked files + local untracked + untracked=$(git ls-files --others --exclude-standard) + if [ -n "$untracked" ]; then + echo "Warning: untracked files found in $context:" + echo "$untracked" + echo "Continue anyway? (y/n)" + read -r response + if [[ "$response" == "y" || "$response" == "Y" ]]; then + echo "Continuing with untracked files." + else + >&2 echo "Aborting due to untracked files." + exit 1 + fi + fi +} diff --git a/release/create-release-branch.sh b/release/create-release-branch.sh index eb57472..abce818 100755 --- a/release/create-release-branch.sh +++ b/release/create-release-branch.sh @@ -1,84 +1,73 @@ #!/usr/bin/env bash # -# See README.adoc +# See README.md # set -euo pipefail # set -x +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + REMOTE="origin" -#---------------------------------------------------------------------------------------------------- -# tags should be semver-compatible e.g. 23.1 and not 23.01 -# this is needed for cargo commands to work properly: although it is not strictly needed -# for the name of the release branch, the branch naming will be consistent with the cargo versioning. -#---------------------------------------------------------------------------------------------------- -RELEASE_REGEX="^[0-9][0-9]\.([1-9]|[1][0-2])$" - -update_products() { - if [ -d "$BASE_DIR/$DOCKER_IMAGES_REPO" ]; then - echo "Directory exists. Switching to ${RELEASE_BRANCH} branch and Updating..." - cd "$BASE_DIR/$DOCKER_IMAGES_REPO" - git pull && git switch "${RELEASE_BRANCH}" # Switch to local branch (remote doesn't yet exist) - else - echo "Repo directory ($BASE_DIR/$DOCKER_IMAGES_REPO) doesn't exist. Cloning and switching to ${RELEASE_BRANCH} branch" - git clone --branch main --depth 1 "git@github.com:stackabletech/${DOCKER_IMAGES_REPO}.git" "$BASE_DIR/$DOCKER_IMAGES_REPO" - cd "$BASE_DIR/$DOCKER_IMAGES_REPO" - # try to switch to the release branch (if continuing from someone else), or create it - git switch "${RELEASE_BRANCH}" 2> /dev/null || git switch -c "${RELEASE_BRANCH}" - fi +update_products() ( + ensure_clone "$DOCKER_IMAGES_REPO" "--branch main" + cd "$DOCKER_IMAGES_REPO" + assert_cwd_is_repo "$DOCKER_IMAGES_REPO" + assert_clean_index "$DOCKER_IMAGES_REPO" + git pull && git switch "${RELEASE_BRANCH}" 2> /dev/null || git switch -c "${RELEASE_BRANCH}" + assert_on_branch "$RELEASE_BRANCH" + + assert_remote_exists "$REMOTE" "$DOCKER_IMAGES_REPO" push_branch "$DOCKER_IMAGES_REPO" echo echo "Check $BASE_DIR/$DOCKER_IMAGES_REPO" -} - -update_operators() { - while IFS="" read -r operator || [ -n "$operator" ] - do - if [ -d "$BASE_DIR/${operator}" ]; then - echo "Directory exists. Switching to ${RELEASE_BRANCH} branch and Updating..." - cd "$BASE_DIR/${operator}" - git pull && git switch "${RELEASE_BRANCH}" # Switch to local branch (remote doesn't yet exist) - else - echo "Repo directory ($BASE_DIR/$operator) doesn't exist. Cloning and switching to ${RELEASE_BRANCH} branch" - git clone --branch main --depth 1 "git@github.com:stackabletech/${operator}.git" "$BASE_DIR/${operator}" - cd "$BASE_DIR/${operator}" - # try to switch to the release branch (if continuing from someone else), or create it - git switch "${RELEASE_BRANCH}" || git switch -c "${RELEASE_BRANCH}" - fi - push_branch "$operator" - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) -} - -update_demos() { - if [ -d "$BASE_DIR/$DEMOS_REPO" ]; then - cd "$BASE_DIR/$DEMOS_REPO" - git pull && git switch "${RELEASE_BRANCH}" - else - git clone --branch main --depth 1 "git@github.com:stackabletech/${DEMOS_REPO}.git" "$BASE_DIR/$DEMOS_REPO" - cd "$BASE_DIR/$DEMOS_REPO" - git switch "${RELEASE_BRANCH}" 2> /dev/null || git switch -c "${RELEASE_BRANCH}" - fi +) + +update_operator() ( + local operator="$1" + ensure_clone "$operator" "--branch main" + cd "${operator}" + assert_cwd_is_repo "$operator" + assert_clean_index "$operator" + git pull && git switch "${RELEASE_BRANCH}" 2> /dev/null || git switch -c "${RELEASE_BRANCH}" + assert_on_branch "$RELEASE_BRANCH" + assert_remote_exists "$REMOTE" "$operator" + push_branch "$operator" +) + +update_demos() ( + ensure_clone "$DEMOS_REPO" "--branch main" + cd "$DEMOS_REPO" + assert_cwd_is_repo "$DEMOS_REPO" + assert_clean_index "$DEMOS_REPO" + git pull && git switch "${RELEASE_BRANCH}" 2> /dev/null || git switch -c "${RELEASE_BRANCH}" + assert_on_branch "$RELEASE_BRANCH" # Search and replace known references to stackableRelease, container images, branch references. # https://github.com/stackabletech/demos/blob/main/.scripts/update_refs.sh .scripts/update_refs.sh commit + assert_remote_exists "$REMOTE" "$DEMOS_REPO" push_branch "$DEMOS_REPO" -} +) update_repos() { local BASE_DIR="$1"; - - if [ "products" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - update_products - fi - if [ "operators" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - update_operators - fi - if [ "demos" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - update_demos - fi + cd "$BASE_DIR" + + case "$WHAT" in + products) update_products ;; + operators) for_each_operator update_operator ;; + demos) update_demos ;; + all) + update_products + for_each_operator update_operator + update_demos + ;; + esac } push_branch() { @@ -101,15 +90,16 @@ cleanup() { fi } +# TODO: Consider moving validation (validate_release_base_version, validate_what) into parse_inputs parse_inputs() { - RELEASE="" + RELEASE_BASE="" # e.g., 26.3 (YY.M, no patch level) PUSH=false CLEANUP=false WHAT="" while [[ "$#" -gt 0 ]]; do case $1 in - -b|--branch) RELEASE="$2"; shift ;; + -b|--branch) RELEASE_BASE="$2"; shift ;; -w|--what) WHAT="$2"; shift ;; -p|--push) PUSH=true ;; -c|--cleanup) CLEANUP=true ;; @@ -117,40 +107,27 @@ parse_inputs() { esac shift done - #----------------------------------------------------------- - # remove leading and trailing quotes - #----------------------------------------------------------- - RELEASE="${RELEASE%\"}" - RELEASE="${RELEASE#\"}" - RELEASE_BRANCH="release-$RELEASE" + RELEASE_BASE="$(strip_double_quotes "$RELEASE_BASE")" INITIAL_DIR="$PWD" - DOCKER_IMAGES_REPO=$(yq '... comments="" | .images-repo ' "$INITIAL_DIR"/release/config.yaml) - DEMOS_REPO=$(yq '... comments="" | .demos-repo ' "$INITIAL_DIR"/release/config.yaml) - TEMP_RELEASE_FOLDER="/tmp/stackable-$RELEASE_BRANCH" + derive_branch_vars "$RELEASE_BASE" echo "Settings: ${RELEASE_BRANCH}: Push: $PUSH: Cleanup: $CLEANUP" } main() { parse_inputs "$@" - #----------------------------------------------------------- - # check if tag argument provided - #----------------------------------------------------------- - if [ -z "${RELEASE}" ]; then - echo "Usage: create-release-branch.sh -b [-p] [-c] [-w products|operators|demos|all]" - exit 1 - fi - #----------------------------------------------------------- - # check if argument matches our tag regex - #----------------------------------------------------------- - if [[ ! $RELEASE =~ $RELEASE_REGEX ]]; then - echo "Provided branch name [$RELEASE] does not match the required regex pattern [$RELEASE_REGEX]" + + if [ -z "${RELEASE_BASE}" ]; then + >&2 echo "Usage: create-release-branch.sh -b [-p] [-c] [-w products|operators|demos|all]" exit 1 fi - echo "Creating temporary working directory if it doesn't exist [$TEMP_RELEASE_FOLDER]" - mkdir -p "$TEMP_RELEASE_FOLDER" + validate_release_base_version "$RELEASE_BASE" + validate_what "$WHAT" "products" "operators" "demos" "all" + check_common_dependencies + + ensure_temp_folder update_repos "$TEMP_RELEASE_FOLDER" cleanup "$TEMP_RELEASE_FOLDER" } diff --git a/release/create-release-candidate-branch.sh b/release/create-release-candidate-branch.sh index 5e1a9ae..0f0655f 100755 --- a/release/create-release-candidate-branch.sh +++ b/release/create-release-candidate-branch.sh @@ -1,176 +1,187 @@ #!/usr/bin/env bash # -# See README.adoc +# See README.md # set -euo pipefail # set -x -# tags should be semver-compatible e.g. 23.1.1 not 23.01.1 -# this is needed for cargo commands to work properly -# optional release-candidate suffixes are in the form: -# - rc-1, e.g. 23.1.1-rc1, 23.12.1-rc12 etc. -TAG_REGEX="^[0-9][0-9]\.([1-9]|[1][0-2])\.[0-9]+(-rc[0-9]+)?$" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + REMOTE="origin" PR_MSG="> [!CAUTION] > ## DO NOT MERGE MANUALLY! > This branch will be merged (and the commit tagged) by stackable-utils once any necessary commits have been cherry-picked to here from the main branch." -rc_branch_products() { - # assume that the branch exists and has either been pushed or has been created locally - cd "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" - - # the PR branch should already exist - git switch "$PR_BRANCH" - update_product_images_changelogs - - git commit -sam "chore: Release $RELEASE_TAG" +# Commit staged release changes and push the PR branch. +# Skips the commit if nothing is staged (idempotent for re-runs). +# Asserts we are on the correct branch before committing and that +# the remote is correct before pushing. +# +# Usage: +# commit_and_push_rc "$DOCKER_IMAGES_REPO" +# commit_and_push_rc "$operator" +commit_and_push_rc() { + local repo="$1" + assert_on_branch "$PR_BRANCH" + if git diff --cached --quiet; then + echo "No changes to commit for $repo (already up to date)" + else + git commit -sm "chore: Release $RELEASE_TAG" + # TODO: Assert we are some commits ahead of the release branch (we just committed) + assert_clean_index "$repo" + fi + assert_remote_exists "$REMOTE" "$repo" push_branch } -rc_branch_operators() { - while IFS="" read -r operator || [ -n "$operator" ]; do - cd "${TEMP_RELEASE_FOLDER}/${operator}" - git switch "$PR_BRANCH" - - # Update git submodules if needed - if [ -f .gitmodules ]; then - git submodule update --recursive --init - fi - - # set tag version where relevant - cargo set-version --offline --workspace "$RELEASE_TAG" - cargo update --workspace - # Run via nix-shell for the correct dependencies. Makefile already calls - # nix stuff, so it shouldn't be a problem for non-nix users. - nix-shell --run 'make regenerate-charts' - nix-shell --run 'make regenerate-nix' - - update_code "$TEMP_RELEASE_FOLDER/${operator}" +rc_branch_products() ( + # assume that the branch exists and has either been pushed or has been created locally + cd "$DOCKER_IMAGES_REPO" + assert_cwd_is_repo "$DOCKER_IMAGES_REPO" + assert_clean_index "$DOCKER_IMAGES_REPO" - # ensure .j2 changes are resolved - "$TEMP_RELEASE_FOLDER/${operator}"/scripts/docs_templating.sh + # the PR branch should already exist + git switch "$PR_BRANCH" + assert_on_branch "$PR_BRANCH" + update_changelog ./CHANGELOG.md "$RELEASE_TAG" + git add CHANGELOG.md + verify_release "." "$RELEASE_TAG" "$RELEASE_BASE" + commit_and_push_rc "$DOCKER_IMAGES_REPO" +) + +rc_branch_operator() ( + local operator="$1" + cd "${operator}" + assert_cwd_is_repo "$operator" + assert_clean_index "$operator" + git switch "$PR_BRANCH" + assert_on_branch "$PR_BRANCH" - # inserts a single line with tag and date - update_changelog "$TEMP_RELEASE_FOLDER/${operator}" + # Update git submodules if needed + if [ -f .gitmodules ]; then + git submodule update --recursive --init + fi - git commit -sam "chore: Release $RELEASE_TAG" - push_branch - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) -} + # set tag version where relevant + cargo set-version --offline --workspace "$RELEASE_TAG" + cargo update --workspace + git add Cargo.toml Cargo.lock + + # Run via nix-shell for the correct dependencies. Makefile already calls + # nix stuff, so it shouldn't be a problem for non-nix users. + # + # LIBGIT2_NO_PKG_CONFIG: Workaround for non-NixOS users. nix-shell provides + # libgit2 at build time (found via pkg-config), but LD_LIBRARY_PATH may not + # include the nix store at runtime, causing a crash. This forces libgit2-sys + # to statically link its bundled copy instead. The proper fix belongs in the + # operator repos' nix shells. + LIBGIT2_NO_PKG_CONFIG=1 nix-shell --run 'make regenerate-charts' + # TODO: These make targets can modify many paths. Ideally we would + # explicitly add the known output paths instead of staging all changes. + git add deploy/helm extra/ + + nix-shell --run 'make regenerate-nix' + git add Cargo.nix crate-hashes.json nix/ + + update_code "$TEMP_RELEASE_FOLDER/${operator}" + git add docs/ tests/ + + # ensure .j2 changes are resolved + "$TEMP_RELEASE_FOLDER/${operator}"/scripts/docs_templating.sh + git add docs/ + + # inserts a single line with tag and date + update_changelog ./CHANGELOG.md "$RELEASE_TAG" + git add CHANGELOG.md + + verify_release "." "$RELEASE_TAG" "$RELEASE_BASE" + commit_and_push_rc "$operator" +) rc_branch_repos() { - if [ "products" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - rc_branch_products - fi - if [ "operators" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - rc_branch_operators - fi + cd "$TEMP_RELEASE_FOLDER" + case "$WHAT" in + products) rc_branch_products ;; + operators) for_each_operator rc_branch_operator ;; + all) + rc_branch_products + for_each_operator rc_branch_operator + ;; + esac } -check_tag_is_valid() { - git fetch --tags - # check tags: N.B. look for exact match - if git tag --list | grep -E "^$RELEASE_TAG\$"; then - >&2 echo "Tag $RELEASE_TAG already exists!" - exit 1 - fi - - # Do we want proper semver version checking? - # We should switch this script to python if so. - #EXISTING_TAGS=$(git tag --list | grep -E "$RELEASE" | sort -V) - #for EXISTING_TAG in $EXISTING_TAGS; do - # if [[ "$RELEASE_TAG" < "$EXISTING_TAG" ]]; then - # >&2 echo "Error: Proposed tag $RELEASE_TAG is earlier than existing tag $EXISTING_TAG." - # exit 1 - # fi - #done -} - -check_products() { +check_products() ( echo "Checking products" - if [ ! -d "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" ]; then - echo "Cloning folder: $TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" - # $TEMP_RELEASE_FOLDER has already been created in main() - git clone "git@github.com:stackabletech/${DOCKER_IMAGES_REPO}.git" "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" - fi - cd "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" + ensure_clone "$DOCKER_IMAGES_REPO" + cd "$DOCKER_IMAGES_REPO" + assert_cwd_is_repo "$DOCKER_IMAGES_REPO" + assert_clean_index "$DOCKER_IMAGES_REPO" # Need to update here because if we deleted the local state, or someone else continues - # we might be back on main, or on the release branch without having pulled updates from fixes. + # we might be back on main, or on the release branch without having pulled updates from fixes. git fetch && git switch "$RELEASE_BRANCH" && git pull + assert_on_branch "$RELEASE_BRANCH" - # switch to the release branch, which should exist as tagging - # is subsequent to creating the branch. - # Note, if this needs to check the branch exists locally, then use: - # "^[ *]*$RELEASE_BRANCH\$" - if ! git branch -a | grep -E "$RELEASE_BRANCH\$"; then - >&2 echo "Expected release branch is missing: $RELEASE_BRANCH" - exit 1 - fi + # The release branch should exist (created in a prior step) + # NOTE: Do we need to check if the branch exists locally? + assert_remote_branch_exists "$REMOTE" "$RELEASE_BRANCH" - # the new PR should not exist, otherwise a duplicate commit - # will be prepared - # Note, if this needs to check the branch exists locally, then use: - # "^[ *]*$PR_BRANCH\$" - if git branch -a | grep -E "$PR_BRANCH\$"; then - >&2 echo "PR branch already exists: ${REMOTE}/$PR_BRANCH" - exit 1 - fi + # The PR branch should not exist yet, otherwise a duplicate commit will be prepared + # NOTE: Do we need to check if the branch DOES NOT exist locally? + assert_remote_branch_not_exists "$REMOTE" "$PR_BRANCH" # create a new branch for the PR off of this git switch -c "$PR_BRANCH" "$RELEASE_BRANCH" + assert_on_branch "$PR_BRANCH" - check_tag_is_valid -} + assert_tag_not_exists "$REMOTE" "$RELEASE_TAG" +) -check_operators() { - echo "Checking operators" +check_operator() ( + local operator="$1" + echo "Operator: $operator" + ensure_clone "$operator" + cd "${operator}" + assert_cwd_is_repo "$operator" + assert_clean_index "$operator" - while IFS="" read -r operator || [ -n "$operator" ]; do - echo "Operator: $operator" - if [ ! -d "$TEMP_RELEASE_FOLDER/${operator}" ]; then - echo "Cloning folder: $TEMP_RELEASE_FOLDER/${operator}" - # $TEMP_RELEASE_FOLDER has already been created in main() - git clone "git@github.com:stackabletech/${operator}.git" "$TEMP_RELEASE_FOLDER/${operator}" + # Need to update here because if we deleted the local state, or someone else continues + # we might be back on main, or on the release branch without having pulled updates from fixes. + git fetch && git switch "$RELEASE_BRANCH" && git pull + assert_on_branch "$RELEASE_BRANCH" + # The release branch should exist (created in a prior step) + # NOTE: Do we need to check if the branch exists locally? + assert_remote_branch_exists "$REMOTE" "$RELEASE_BRANCH" - fi - cd "$TEMP_RELEASE_FOLDER/${operator}" - - # Need to update here because if we deleted the local state, or someone else continues - # we might be back on main, or on the release branch without having pulled updates from fixes. - git fetch && git switch "$RELEASE_BRANCH" && git pull - # Note, if this needs to check the branch exists locally, then use: - # "^[ *]*$RELEASE_BRANCH\$" - if ! git branch -a | grep -E "$RELEASE_BRANCH\$"; then - >&2 echo "Expected release branch is missing: ${operator}/$RELEASE_BRANCH" - exit 1 - fi + # The PR branch should not exist yet, otherwise a duplicate commit will be prepared + # NOTE: Do we need to check if the branch DOES NOT exist locally? + assert_remote_branch_not_exists "$REMOTE" "$PR_BRANCH" - # the new PR should not exist, otherwise a duplicate commit - # will be prepared - # Note, if this needs to check the branch exists locally, then use: - # "^[ *]*$PR_BRANCH\$" - if git branch -a | grep -E "$PR_BRANCH\$"; then - >&2 echo "PR branch already exists: ${operator}/$PR_BRANCH" - exit 1 - fi + # create a new branch for the PR off of this + git switch -c "$PR_BRANCH" "$RELEASE_BRANCH" + assert_on_branch "$PR_BRANCH" - # create a new branch for the PR off of this - git switch -c "$PR_BRANCH" "$RELEASE_BRANCH" - - check_tag_is_valid - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) -} + assert_tag_not_exists "$REMOTE" "$RELEASE_TAG" +) checks() { - if [ "products" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - check_products - fi - if [ "operators" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - check_operators - fi + cd "$TEMP_RELEASE_FOLDER" + case "$WHAT" in + products) check_products ;; + operators) + echo "Checking operators" + for_each_operator check_operator + ;; + all) + check_products + echo "Checking operators" + for_each_operator check_operator + ;; + esac } update_code() { @@ -178,7 +189,7 @@ update_code() { echo "Updating antora docs for $1" # antora version should be major.minor, not patch level - yq -i ".version = \"${RELEASE}\"" "$1/docs/antora.yml" + yq -i ".version = \"${RELEASE_BASE}\"" "$1/docs/antora.yml" yq -i '.prerelease = false' "$1/docs/antora.yml" # Not all operators have a getting started guide @@ -192,7 +203,7 @@ update_code() { # We assume that the tag (e.g. 23.7.1) is applied to an earlier tag in the same # release (e.g. 23.7.0) so search+replace on the major.minor tag will suffice. # TODO: this may pick up versions of external components as well. - yq -i "(.versions.[] | select(. == \"${RELEASE}*\")) |= \"${RELEASE_TAG}\"" "$1/docs/templating_vars.yaml" + yq -i "(.versions.[] | select(. == \"${RELEASE_BASE}*\")) |= \"${RELEASE_TAG}\"" "$1/docs/templating_vars.yaml" yq -i ".helm.repo_name |= sub(\"stackable-dev\", \"stackable-stable\")" "$1/docs/templating_vars.yaml" yq -i ".helm.repo_url |= sub(\"helm-dev\", \"helm-stable\")" "$1/docs/templating_vars.yaml" @@ -212,7 +223,7 @@ update_code() { # do this for patch releases/release candidates too. # i.e. replace 24.11.0-rc1 with 24.11.0, 24.7.0 with 24.7.1 etc. - yq -i "(.releases.tests.products[].operatorVersion | select(. == \"${RELEASE}*\")) |= \"${RELEASE_TAG}\"" "$1/tests/release.yaml" + yq -i "(.releases.tests.products[].operatorVersion | select(. == \"${RELEASE_BASE}*\")) |= \"${RELEASE_TAG}\"" "$1/tests/release.yaml" # Some tests perform **label** inspection and for (only) these cases specific labels should be updated. # N.B. don't do this for all test files as not all images will necessarily exist for the given release tag. @@ -239,16 +250,8 @@ cleanup() { fi } -update_changelog() { - TODAY=$(date +'%Y-%m-%d') - sed -i "s/^.*unreleased.*/## [Unreleased]\n\n## [$RELEASE_TAG] - $TODAY/I" "$1"/CHANGELOG.md -} - -update_product_images_changelogs() { - TODAY=$(date +'%Y-%m-%d') - sed -i "s/^.*unreleased.*/## [Unreleased]\n\n## [$RELEASE_TAG] - $TODAY/I" ./CHANGELOG.md -} +# TODO: Consider moving validation (validate_tag, validate_what) into parse_inputs parse_inputs() { RELEASE_TAG="" PUSH=false @@ -275,48 +278,22 @@ parse_inputs() { shift done - # remove leading and trailing quotes - RELEASE_TAG="${RELEASE_TAG%\"}" - RELEASE_TAG="${RELEASE_TAG#\"}" - - # for a tag of e.g. 23.1.1, the release branch (already created) will be 23.1 - RELEASE="$(cut -d'.' -f1,2 <<< "$RELEASE_TAG")" - RELEASE_BRANCH="release-$RELEASE" - # N.B. this has to match what is used in other scripts - PR_BRANCH="pr-$RELEASE_TAG" + RELEASE_TAG="$(strip_double_quotes "$RELEASE_TAG")" INITIAL_DIR="$PWD" - DOCKER_IMAGES_REPO=$(yq '... comments="" | .images-repo ' "$INITIAL_DIR"/release/config.yaml) - TEMP_RELEASE_FOLDER="/tmp/stackable-$RELEASE_BRANCH" + derive_tag_vars "$RELEASE_TAG" echo "Settings: ${RELEASE_BRANCH}: Push: $PUSH: Cleanup: $CLEANUP" } check_dependencies() { - # check for a globally configured git user - if ! git_user=$(git config --global --includes --get user.name) \ - || ! git_email=$(git config --global --includes --get user.email); then - >&2 echo "Error: global git user name/email is not set." - exit 1 - else - echo "global git user: $git_user <$git_email>" - echo "Is this correct? (y/n)" - read -r response - if [[ "$response" == "y" || "$response" == "Y" ]]; then - echo "Proceeding with $git_user <$git_email>" - else - >&2 echo "User not accepted. Exiting." - exit 1 - fi - fi + check_common_dependencies - # check gh authentication: if this fails you will need to e.g. gh auth login - gh auth status - yq --version + # Additional dependencies for operator RC branch creation python --version cargo --version cargo set-version --version - # check for jinja2-cli including pyyaml package + # jinja2-cli including pyyaml package (for docs templating) jinja2 --version python -m pip show pyyaml } @@ -324,23 +301,15 @@ check_dependencies() { main() { parse_inputs "$@" - # check if tag argument provided if [ -z "${RELEASE_TAG}" ]; then >&2 echo "Usage: create-release-candidate-branch.sh -t [-p] [-c] [-w products|operators|all]" exit 1 fi - # check if argument matches our tag regex - if [[ ! $RELEASE_TAG =~ $TAG_REGEX ]]; then - >&2 echo "Provided tag [$RELEASE_TAG] does not match the required tag regex pattern [$TAG_REGEX]" - exit 1 - fi - - if [ ! -d "$TEMP_RELEASE_FOLDER" ]; then - echo "Creating folder for cloning docker images and/or operators: [$TEMP_RELEASE_FOLDER]" - mkdir -p "$TEMP_RELEASE_FOLDER" - fi + validate_tag "$RELEASE_TAG" + validate_what "$WHAT" "products" "operators" "all" + ensure_temp_folder check_dependencies # sanity checks before we start: folder, branches etc. diff --git a/release/merge-release-candidate.sh b/release/merge-release-candidate.sh index 213acad..8c9ba1e 100755 --- a/release/merge-release-candidate.sh +++ b/release/merge-release-candidate.sh @@ -5,6 +5,11 @@ set -euo pipefail # set -x +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +# TODO: Consider moving validation (validate_tag, validate_what) into parse_inputs parse_inputs() { RELEASE_TAG="" PUSH=false @@ -30,47 +35,45 @@ parse_inputs() { shift done - # remove leading and trailing quotes - RELEASE_TAG="${RELEASE_TAG%\"}" - RELEASE_TAG="${RELEASE_TAG#\"}" - # N.B. this has to match what is used in other scripts - PR_BRANCH="pr-$RELEASE_TAG" + RELEASE_TAG="$(strip_double_quotes "$RELEASE_TAG")" INITIAL_DIR="$PWD" - DOCKER_IMAGES_REPO=$(yq '... comments="" | .images-repo ' "$INITIAL_DIR"/release/config.yaml) + derive_tag_vars "$RELEASE_TAG" echo "Settings: ${PR_BRANCH}: Push: $PUSH:" } -merge_operators() { - read -p "Ask someone to approve all of the operator PRs, then press Enter" - while IFS="" read -r operator || [ -n "$operator" ]; do - echo "Operator: $operator" +merge_operator() { + local operator="$1" + echo "Operator: $operator" + if $PUSH; then + STATE=$(gh pr view "${PR_BRANCH}" -R stackabletech/"${operator}" --jq '.state' --json state) + else + echo "Dry-run: skipping PR existence check for ${operator}" + STATE="OPEN" + fi + if [[ "$STATE" == "OPEN" ]]; then + echo "Processing ${operator} in branch ${PR_BRANCH} with state ${STATE}" if $PUSH; then - STATE=$(gh pr view "${PR_BRANCH}" -R stackabletech/"${operator}" --jq '.state' --json state) - else - # It is possible to dry-run with the PR existing, but we will simply use OPEN - echo "Dry-run: pretending the PR exists and is open" - STATE="OPEN" - fi - if [[ "$STATE" == "OPEN" ]]; then - echo "Processing ${operator} in branch ${PR_BRANCH} with state ${STATE}" - if $PUSH; then - echo "Reviewing..." - # TODO (@NickLarsenNZ): Check if the review is merged, else loop the following - # TODO (@NickLarsenNZ): Allow review if the PR author is not the current `gh` user, otherwise wait. - # gh pr review "${PR_BRANCH}" --approve -R stackabletech/"${operator}" - echo "Merging..." - gh pr merge "${PR_BRANCH}" --delete-branch --squash -R stackabletech/"${operator}" - else - echo "Dry-run: not reviewing/merging..." - echo - echo "Please checkout the release branch, and manually run git merge ${PR_BRANCH}" - fi + echo "Reviewing..." + # TODO (@NickLarsenNZ): Check if the review is merged, else loop the following + # TODO (@NickLarsenNZ): Allow review if the PR author is not the current `gh` user, otherwise wait. + # gh pr review "${PR_BRANCH}" --approve -R stackabletech/"${operator}" + echo "Merging..." + gh pr merge "${PR_BRANCH}" --delete-branch --squash -R stackabletech/"${operator}" else - echo "Skipping ${operator}, PR already closed" + echo "Dry-run: not reviewing/merging..." + echo + echo "Please checkout the release branch, and manually run git merge ${PR_BRANCH}" fi - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) + else + echo "Skipping ${operator}, PR already closed" + fi +} + +merge_operators() { + read -p "Ask someone to approve all of the operator PRs, then press Enter" + for_each_operator merge_operator } merge_products() { @@ -78,8 +81,7 @@ merge_products() { if $PUSH; then STATE=$(gh pr view "${PR_BRANCH}" -R stackabletech/"${DOCKER_IMAGES_REPO}" --jq '.state' --json state) else - # It is possible to dry-run with the PR existing, but we will simply use OPEN - echo "Dry-run: pretending the PR exists and is open" + echo "Dry-run: skipping PR existence check for ${DOCKER_IMAGES_REPO}" STATE="OPEN" fi if [[ "$STATE" == "OPEN" ]]; then @@ -103,44 +105,30 @@ merge_products() { } merge() { - if [ "products" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - merge_products - fi - if [ "operators" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - merge_operators - fi + case "$WHAT" in + products) merge_products ;; + operators) merge_operators ;; + all) + merge_products + merge_operators + ;; + esac } check_dependencies() { - # check for a globally configured git user - if ! git_user=$(git config --global --includes --get user.name) \ - || ! git_email=$(git config --global --includes --get user.email); then - >&2 echo "Error: global git user name/email is not set." - exit 1 - else - echo "global git user: $git_user <$git_email>" - echo "Is this correct? (y/n)" - read -r response - if [[ "$response" == "y" || "$response" == "Y" ]]; then - echo "Proceeding with $git_user <$git_email>" - else - >&2 echo "User not accepted. Exiting." - exit 1 - fi - fi - # check gh authentication: if this fails you will need to e.g. gh auth login - gh auth status + check_common_dependencies } main() { parse_inputs "$@" - # check if tag argument provided if [ -z "${RELEASE_TAG}" ]; then - >&2 echo "Usage: create-release-merge-and-tag.sh -t [-w products|operators|all]" + >&2 echo "Usage: merge-release-candidate.sh -t [-p] [-w products|operators|all]" exit 1 fi + validate_tag "$RELEASE_TAG" + validate_what "$WHAT" "products" "operators" "all" check_dependencies merge } diff --git a/release/post-release.sh b/release/post-release.sh index 6f7f07f..c7c639e 100755 --- a/release/post-release.sh +++ b/release/post-release.sh @@ -1,19 +1,19 @@ #!/usr/bin/env bash # -# See README.adoc +# See README.md # set -euo pipefail # set -x -#----------------------------------------------------------- -# tags should be semver-compatible e.g. 23.1.1 not 23.01.1 -# this is needed for cargo commands to work properly -#----------------------------------------------------------- -TAG_REGEX="^[0-9][0-9]\.([1-9]|[1][0-2])\.[0-9]+$" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + REMOTE="origin" PR_MSG="> [!CAUTION] > ## DO NOT MERGE WITHOUT MANUAL CHECKING! > This PR contains information about commits have been cherry-picked to the release branch from the main branch, and may not reflect the correct chronology. Please check!" +# TODO: Consider moving validation (validate_tag, validate_what) into parse_inputs parse_inputs() { RELEASE_TAG="" PUSH=false @@ -28,115 +28,91 @@ parse_inputs() { esac shift done - #----------------------------------------------------------- - # remove leading and trailing quotes - #----------------------------------------------------------- - RELEASE_TAG="${RELEASE_TAG%\"}" - RELEASE_TAG="${RELEASE_TAG#\"}" - #---------------------------------------------------------------------------------------------------- - # for a tag of e.g. 23.1.1, the release branch (already created) will be 23.1 - #---------------------------------------------------------------------------------------------------- - RELEASE="$(cut -d'.' -f1,2 <<< "$RELEASE_TAG")" - RELEASE_BRANCH="release-$RELEASE" + RELEASE_TAG="$(strip_double_quotes "$RELEASE_TAG")" INITIAL_DIR="$PWD" - DOCKER_IMAGES_REPO=$(yq '... comments="" | .images-repo ' "$INITIAL_DIR"/release/config.yaml) - TEMP_RELEASE_FOLDER="/tmp/stackable-$RELEASE_BRANCH" + derive_tag_vars "$RELEASE_TAG" echo "Settings: $RELEASE_BRANCH: Push: $PUSH" } -# Check that the operator repos have been cloned locally, and that the release +# Check that an operator repo has been cloned locally, and that the release # branch and tag exists. -check_operators() { - while IFS="" read -r OPERATOR || [ -n "$OPERATOR" ] - do - echo "Operator: $OPERATOR" - if [ ! -d "$TEMP_RELEASE_FOLDER/$OPERATOR" ]; then - echo "Cloning folder: $TEMP_RELEASE_FOLDER/$OPERATOR" - # $TEMP_RELEASE_FOLDER has already been created in main() - git clone "git@github.com:stackabletech/${OPERATOR}.git" "$TEMP_RELEASE_FOLDER/$OPERATOR" - fi - cd "$TEMP_RELEASE_FOLDER/$OPERATOR" - - if ! git diff-index --quiet HEAD --; then - >&2 echo "Dirty git index for $OPERATOR. Check working tree or staged changes. Exiting." - exit 2 - fi - - # Note, if this needs to check the branch exists locally, then use: - # "^[ *]*$RELEASE_BRANCH\$" - if ! git branch -a | grep "$RELEASE_BRANCH\$"; then - >&2 echo "Expected release branch is missing: $OPERATOR/$RELEASE_BRANCH" - exit 1 - fi - git fetch --tags - if ! git tag | grep "^$RELEASE_TAG\$"; then - >&2 echo "Expected tag $RELEASE_TAG missing for operator $OPERATOR" - exit 1 - fi - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) -} +check_operator() ( + local OPERATOR="$1" + echo "Operator: $OPERATOR" + ensure_clone "$OPERATOR" + cd "$OPERATOR" + assert_cwd_is_repo "$OPERATOR" + assert_clean_index "$OPERATOR" + # TODO (@NickLarsenNZ): Probably need a pull here + + # The release branch should exist (created in a prior step) + # NOTE: Do we need to check if the branch exists locally? + assert_remote_branch_exists "$REMOTE" "$RELEASE_BRANCH" + git fetch --tags + if ! git tag | grep "^$RELEASE_TAG\$"; then + >&2 echo "Expected tag $RELEASE_TAG missing for operator $OPERATOR" + exit 1 + fi +) -# Update the operator changelogs on main, and check they do not differ from -# the changelog in the release branch. -update_operators() { - while IFS="" read -r OPERATOR || [ -n "$OPERATOR" ] - do - cd "$TEMP_RELEASE_FOLDER/$OPERATOR" - - git checkout main - git pull - - # New branch that updates the CHANGELOG - CHANGELOG_BRANCH="chore/update-changelog-from-release-$RELEASE_TAG" - # Branch out from main - git switch -c "$CHANGELOG_BRANCH" - # Checkout CHANGELOG changes from the release tag - git checkout "$RELEASE_TAG" -- CHANGELOG.md - # Ensure only the CHANGELOG has been modified and there - # are no conflicts. - CHANGELOG_MODIFIED=$(git status --short) - if [ "M CHANGELOG.md" != "$CHANGELOG_MODIFIED" ]; then - echo "Failed to update CHANGELOG.md in main for operator $OPERATOR" - exit 1 - fi - # Commit the updated CHANGELOG. - git add CHANGELOG.md - git commit -sm "Update CHANGELOG.md from release $RELEASE_TAG" - # Maybe push and create pull request - if "$PUSH"; then - git push -u "${REMOTE}" "${CHANGELOG_BRANCH}" - gh pr create --reviewer stackabletech/developers --base main --head "${CHANGELOG_BRANCH}" --title "chore: Update changelog from release ${RELEASE_TAG}" --body "${PR_MSG}" - else - echo "Dry-run: not pushing..." - git push --dry-run "${REMOTE}" "${CHANGELOG_BRANCH}" - gh pr create --reviewer stackabletech/developers --dry-run --base main --head "${CHANGELOG_BRANCH}" --title "chore: Update changelog from release ${RELEASE_TAG}" --body "${PR_MSG}" - fi - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) -} -# Check that the docker-images repo has been cloned locally, and that the release -# branch and tag exists. -check_products() { - if [ ! -d "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" ]; then - echo "Cloning folder: $TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" - # $TEMP_RELEASE_FOLDER has already been created in main() - git clone "git@github.com:stackabletech/${DOCKER_IMAGES_REPO}.git" "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" - fi - cd "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" +# Update an operator's changelog on main, and check it does not differ from +# the changelog in the release branch. +update_operator() ( + local OPERATOR="$1" + cd "$OPERATOR" + assert_cwd_is_repo "$OPERATOR" + assert_clean_index "$OPERATOR" - if ! git diff-index --quiet HEAD --; then - >&2 echo "Dirty git index for $DOCKER_IMAGES_REPO. Check working tree or staged changes. Exiting." - exit 2 - fi + git checkout main + assert_on_branch "main" + git pull + # TODO: Assert we are in sync with origin/main (0 ahead, 0 behind after pull) - # Note, if this needs to check the branch exists locally, then use: - # "^[ *]*$RELEASE_BRANCH\$" - if ! git branch -a | grep "$RELEASE_BRANCH\$"; then - >&2 echo "Expected release branch is missing: $DOCKER_IMAGES_REPO/$RELEASE_BRANCH" + # New branch that updates the CHANGELOG + CHANGELOG_BRANCH="chore/update-changelog-from-release-$RELEASE_TAG" + # Branch out from main + git switch -c "$CHANGELOG_BRANCH" + assert_on_branch "$CHANGELOG_BRANCH" + # Checkout CHANGELOG changes from the release tag + git checkout "$RELEASE_TAG" -- CHANGELOG.md + # Ensure only the CHANGELOG has been modified and there + # are no conflicts. + CHANGELOG_MODIFIED=$(git status --short) + if [ "M CHANGELOG.md" != "$CHANGELOG_MODIFIED" ]; then + echo "Failed to update CHANGELOG.md in main for operator $OPERATOR" exit 1 fi + # Commit the updated CHANGELOG. + git add CHANGELOG.md + git commit -sm "Update CHANGELOG.md from release $RELEASE_TAG" + assert_clean_index "$OPERATOR" + # Maybe push and create pull request + assert_remote_exists "$REMOTE" "$OPERATOR" + if "$PUSH"; then + git push -u "${REMOTE}" "${CHANGELOG_BRANCH}" + gh pr create --reviewer stackabletech/developers --base main --head "${CHANGELOG_BRANCH}" --title "chore: Update changelog from release ${RELEASE_TAG}" --body "${PR_MSG}" + else + echo "Dry-run: not pushing..." + git push --dry-run "${REMOTE}" "${CHANGELOG_BRANCH}" + gh pr create --reviewer stackabletech/developers --dry-run --base main --head "${CHANGELOG_BRANCH}" --title "chore: Update changelog from release ${RELEASE_TAG}" --body "${PR_MSG}" + fi +) + +# Check that the docker-images repo has been cloned locally, and that the release +# branch and tag exists. +check_products() ( + ensure_clone "$DOCKER_IMAGES_REPO" + cd "$DOCKER_IMAGES_REPO" + assert_cwd_is_repo "$DOCKER_IMAGES_REPO" + assert_clean_index "$DOCKER_IMAGES_REPO" + # TODO (@NickLarsenNZ): Probably need a pull here + + # The release branch should exist (created in a prior step) + # NOTE: Do we need to check if the branch exists locally? + assert_remote_branch_exists "$REMOTE" "$RELEASE_BRANCH" git fetch --tags # check tags: N.B. look for exact match @@ -144,20 +120,25 @@ check_products() { >&2 echo "Expected tag $RELEASE_TAG missing for $DOCKER_IMAGES_REPO" exit 1 fi -} +) # Update the docker-images changelogs on main, and check they do not differ from # the changelog in the release branch. -update_products() { - cd "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" +update_products() ( + cd "$DOCKER_IMAGES_REPO" + assert_cwd_is_repo "$DOCKER_IMAGES_REPO" + assert_clean_index "$DOCKER_IMAGES_REPO" git checkout main + assert_on_branch "main" git pull + # TODO: Assert we are in sync with origin/main (0 ahead, 0 behind after pull) # New branch that updates the CHANGELOG CHANGELOG_BRANCH="chore/update-changelog-from-release-$RELEASE_TAG" # Branch out from main git switch -c "$CHANGELOG_BRANCH" + assert_on_branch "$CHANGELOG_BRANCH" # Checkout CHANGELOG changes from the release tag git checkout "$RELEASE_TAG" -- CHANGELOG.md # Ensure only the CHANGELOG has been modified and there @@ -170,7 +151,9 @@ update_products() { # Commit the updated CHANGELOG. git add CHANGELOG.md git commit -sm "Update CHANGELOG.md from release $RELEASE_TAG" + assert_clean_index "$DOCKER_IMAGES_REPO" # Maybe push and create pull request + assert_remote_exists "$REMOTE" "$DOCKER_IMAGES_REPO" if "$PUSH"; then git push -u "${REMOTE}" "${CHANGELOG_BRANCH}" gh pr create --reviewer stackabletech/developers --base main --head "${CHANGELOG_BRANCH}" --title "chore: Update changelog from release ${RELEASE_TAG}" --body "${PR_MSG}" @@ -179,48 +162,45 @@ update_products() { git push --dry-run "${REMOTE}" "${CHANGELOG_BRANCH}" gh pr create --reviewer stackabletech/developers --dry-run --base main --head "${CHANGELOG_BRANCH}" --title "chore: Update changelog from release ${RELEASE_TAG}" --body "${PR_MSG}" fi -} +) main() { parse_inputs "$@" - #----------------------------------------------------------- - # check if tag argument provided - #----------------------------------------------------------- + if [ -z "${RELEASE_TAG}" ]; then - echo "Usage: post-release.sh [options]" - echo "-t " - echo "-p Push changes. Default: false" + >&2 echo "Usage: post-release.sh -t [-p] [-w products|operators|all]" exit 1 fi - #----------------------------------------------------------- - # check if argument matches our tag regex - #----------------------------------------------------------- - if [[ ! $RELEASE_TAG =~ $TAG_REGEX ]]; then - echo "Provided tag [$RELEASE_TAG] does not match the required tag regex pattern [$TAG_REGEX]" - exit 1 - fi - - if [ ! -d "$TEMP_RELEASE_FOLDER" ]; then - echo "Creating folder for cloning docker images and operators: [$TEMP_RELEASE_FOLDER]" - mkdir -p "$TEMP_RELEASE_FOLDER" - fi - - if [ "products" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - # sanity checks before we start: folder, branches etc. - check_products - - echo "Update $DOCKER_IMAGES_REPO main changelog for release $RELEASE_TAG" - update_products - fi - if [ "operators" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - # sanity checks before we start: folder, branches etc. - check_operators - - echo "Update the operator main changelog for release $RELEASE_TAG" - update_operators - fi + # Post-release is only for final releases, not release candidates. + validate_tag --no-rc "$RELEASE_TAG" + validate_what "$WHAT" "products" "operators" "all" + check_common_dependencies + + ensure_temp_folder + cd "$TEMP_RELEASE_FOLDER" + + case "$WHAT" in + products) + check_products + echo "Update $DOCKER_IMAGES_REPO main changelog for release $RELEASE_TAG" + update_products + ;; + operators) + for_each_operator check_operator + echo "Update the operator main changelog for release $RELEASE_TAG" + for_each_operator update_operator + ;; + all) + check_products + echo "Update $DOCKER_IMAGES_REPO main changelog for release $RELEASE_TAG" + update_products + for_each_operator check_operator + echo "Update the operator main changelog for release $RELEASE_TAG" + for_each_operator update_operator + ;; + esac } main "$@" diff --git a/release/release-branch-hygiene.sh b/release/release-branch-hygiene.sh index d4f156f..fa90de2 100755 --- a/release/release-branch-hygiene.sh +++ b/release/release-branch-hygiene.sh @@ -1,39 +1,33 @@ #!/usr/bin/env bash # -# See README.adoc +# See README.md # set -euo pipefail # set -x +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + REMOTE="origin" # Collects "||<url>" entries for every PR raised during the run, # so we can print a consolidated summary at the end. CREATED_PRS=() -#---------------------------------------------------------------------------------------------------- -# tags should be semver-compatible e.g. 23.1 and not 23.01 -# this is needed for cargo commands to work properly: although it is not strictly needed -# for the name of the release branch, the branch naming will be consistent with the cargo versioning. -#---------------------------------------------------------------------------------------------------- -RELEASE_REGEX="^[0-9][0-9]\.([1-9]|[1][0-2])$" update_products() { - if [ -d "$BASE_DIR/$DOCKER_IMAGES_REPO" ]; then - echo "Directory exists. Switching to ${RELEASE_BRANCH} branch and Updating..." - cd "$BASE_DIR/$DOCKER_IMAGES_REPO" - git switch "${RELEASE_BRANCH}" && git pull - else - echo "Repo directory ($BASE_DIR/$DOCKER_IMAGES_REPO) doesn't exist. Cloning and switching to ${RELEASE_BRANCH} branch" - git clone --branch ${RELEASE_BRANCH} --depth 1 "git@github.com:stackabletech/${DOCKER_IMAGES_REPO}.git" "$BASE_DIR/$DOCKER_IMAGES_REPO" - cd "$BASE_DIR/$DOCKER_IMAGES_REPO" - # try to switch to the release branch (if continuing from someone else), or create it - git switch "${RELEASE_BRANCH}" 2> /dev/null || git switch -c "${RELEASE_BRANCH}" - fi + ensure_clone "$DOCKER_IMAGES_REPO" "--branch $RELEASE_BRANCH" + pushd "$DOCKER_IMAGES_REPO" > /dev/null + assert_cwd_is_repo "$DOCKER_IMAGES_REPO" + assert_clean_index "$DOCKER_IMAGES_REPO" + git switch "${RELEASE_BRANCH}" && git pull + assert_on_branch "$RELEASE_BRANCH" # Create the work branch from the release branch so changes go through a PR # rather than being pushed directly to the release branch. git switch -c "${WORK_BRANCH}" + assert_on_branch "$WORK_BRANCH" - echo "Pls manually bump the UBI base images in $BASE_DIR/$DOCKER_IMAGES_REPO" + echo "Pls manually bump the UBI base images in $DOCKER_IMAGES_REPO" echo 'Tip: I found the following images when searching for "registry.access.redhat.com/ubi" in Dockerfiles:' grep -r 'FROM registry.access.redhat.com/ubi' **/Dockerfile @@ -47,57 +41,58 @@ update_products() { git commit -m "chore: UBI base image bumps" fi + assert_remote_exists "$REMOTE" "$DOCKER_IMAGES_REPO" raise_pr "$DOCKER_IMAGES_REPO" "chore: UBI base image bumps" echo - echo "Check $BASE_DIR/$DOCKER_IMAGES_REPO" + echo "Check $DOCKER_IMAGES_REPO" + popd > /dev/null } -update_operators() { - while IFS="" read -r operator || [ -n "$operator" ] - do - echo ">>> Now working on ${operator}" - - if [ -d "$BASE_DIR/${operator}" ]; then - echo "Directory exists. Switching to ${RELEASE_BRANCH} branch and Updating..." - cd "$BASE_DIR/${operator}" - git switch "${RELEASE_BRANCH}" && git pull - else - echo "Repo directory ($BASE_DIR/$operator) doesn't exist. Cloning and switching to ${RELEASE_BRANCH} branch" - git clone --branch ${RELEASE_BRANCH} --depth 1 "git@github.com:stackabletech/${operator}.git" "$BASE_DIR/${operator}" - cd "$BASE_DIR/${operator}" - # try to switch to the release branch (if continuing from someone else), or create it - git switch "${RELEASE_BRANCH}" || git switch -c "${RELEASE_BRANCH}" - fi +update_operator() { + local operator="$1" + echo ">>> Now working on ${operator}" - # Create the work branch from the release branch so changes go through a PR - # rather than being pushed directly to the release branch. - git switch -c "${WORK_BRANCH}" + ensure_clone "$operator" "--branch $RELEASE_BRANCH" + pushd "${operator}" > /dev/null + assert_cwd_is_repo "$operator" + assert_clean_index "$operator" + git switch "${RELEASE_BRANCH}" && git pull + assert_on_branch "$RELEASE_BRANCH" - cargo update - # cargo test # Will be done by CI and takes too long - make regenerate-nix - # We are explicitly not regenerating the CRDs, as we don't want CRD changes to sneak in. - # We rather let the CI checks fail and inspect manually. - git add Cargo.lock Cargo.nix - git commit -m "chore: Rust dependency patch level updates" - - echo "FYI, these are the major/minor bumps we did't do:" - cargo +nightly -Z unstable-options update --breaking --dry-run - - raise_pr "$operator" "chore: Rust dependency patch level updates" - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) + # Create the work branch from the release branch so changes go through a PR + # rather than being pushed directly to the release branch. + git switch -c "${WORK_BRANCH}" + assert_on_branch "$WORK_BRANCH" + + cargo update + # cargo test # Will be done by CI and takes too long + make regenerate-nix + # We are explicitly not regenerating the CRDs, as we don't want CRD changes to sneak in. + # We rather let the CI checks fail and inspect manually. + git add Cargo.lock Cargo.nix + git commit -m "chore: Rust dependency patch level updates" + + echo "FYI, these are the major/minor bumps we didn't do:" + cargo +nightly -Z unstable-options update --breaking --dry-run + + assert_remote_exists "$REMOTE" "$operator" + raise_pr "$operator" "chore: Rust dependency patch level updates" + popd > /dev/null } update_repos() { local BASE_DIR="$1"; - - if [ "products" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - update_products - fi - if [ "operators" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - update_operators - fi + cd "$BASE_DIR" + + case "$WHAT" in + products) update_products ;; + operators) for_each_operator update_operator ;; + all) + update_products + for_each_operator update_operator + ;; + esac } raise_pr() { @@ -148,24 +143,16 @@ print_summary() { done } -cleanup() { - local BASE_DIR="$1"; - - if $CLEANUP; then - echo "Cleaning up..." - rm -rf "$BASE_DIR" - fi -} - +# TODO: Consider moving validation (validate_release_base_version, validate_what) into parse_inputs parse_inputs() { - RELEASE="" + RELEASE_BASE="" # e.g., 26.3 (YY.M, no patch level) PUSH=false CLEANUP=false WHAT="" while [[ "$#" -gt 0 ]]; do case $1 in - -b|--branch) RELEASE="$2"; shift ;; + -b|--branch) RELEASE_BASE="$2"; shift ;; -w|--what) WHAT="$2"; shift ;; -p|--push) PUSH=true ;; -c|--cleanup) CLEANUP=true ;; @@ -173,42 +160,40 @@ parse_inputs() { esac shift done - #----------------------------------------------------------- - # remove leading and trailing quotes - #----------------------------------------------------------- - RELEASE="${RELEASE%\"}" - RELEASE="${RELEASE#\"}" - RELEASE_BRANCH="release-$RELEASE" + + RELEASE_BASE="$(strip_double_quotes "$RELEASE_BASE")" + + INITIAL_DIR="$PWD" + derive_branch_vars "$RELEASE_BASE" # A single timestamp is shared across all repos in this run so the work # branches are easy to correlate. WORK_BRANCH="${RELEASE_BRANCH}-maintenance-$(date +%s)" - INITIAL_DIR="$PWD" - DOCKER_IMAGES_REPO=$(yq '... comments="" | .images-repo ' "$INITIAL_DIR"/release/config.yaml) - TEMP_RELEASE_FOLDER="/tmp/stackable-$RELEASE_BRANCH" - echo "Settings: ${RELEASE_BRANCH}: Work branch: ${WORK_BRANCH}: Push: $PUSH: Cleanup: $CLEANUP" } +cleanup() { + local BASE_DIR="$1"; + + if $CLEANUP; then + echo "Cleaning up..." + rm -rf "$BASE_DIR" + fi +} + main() { parse_inputs "$@" - #----------------------------------------------------------- - # check if tag argument provided - #----------------------------------------------------------- - if [ -z "${RELEASE}" ]; then - echo "Usage: create-release-branch.sh -b <branch> [-p] [-c] [-w products|operators|demos|all]" - exit 1 - fi - #----------------------------------------------------------- - # check if argument matches our tag regex - #----------------------------------------------------------- - if [[ ! $RELEASE =~ $RELEASE_REGEX ]]; then - echo "Provided branch name [$RELEASE] does not match the required regex pattern [$RELEASE_REGEX]" + + if [ -z "${RELEASE_BASE}" ]; then + >&2 echo "Usage: release-branch-hygiene.sh -b <branch> [-p] [-c] [-w products|operators|all]" exit 1 fi - echo "Creating temporary working directory if it doesn't exist [$TEMP_RELEASE_FOLDER]" - mkdir -p "$TEMP_RELEASE_FOLDER" + validate_release_base_version "$RELEASE_BASE" + validate_what "$WHAT" "products" "operators" "all" + check_common_dependencies + + ensure_temp_folder update_repos "$TEMP_RELEASE_FOLDER" cleanup "$TEMP_RELEASE_FOLDER" print_summary diff --git a/release/tag-release-candidate.sh b/release/tag-release-candidate.sh index 2952f6d..9f84125 100755 --- a/release/tag-release-candidate.sh +++ b/release/tag-release-candidate.sh @@ -1,123 +1,124 @@ #!/usr/bin/env bash # -# See README.adoc +# See README.md # set -euo pipefail # set -x -# tags should be semver-compatible e.g. 23.1.1 not 23.01.1 -# this is needed for cargo commands to work properly -# optional release-candidate suffixes are in the form: -# - rc-1, e.g. 23.1.1-rc1, 23.12.1-rc12 etc. -TAG_REGEX="^[0-9][0-9]\.([1-9]|[1][0-2])\.[0-9]+(-rc[0-9]+)?$" -REPOSITORY="origin" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" -tag_products() { +REMOTE="origin" + +tag_products() ( # assume that the branch exists and has either been pushed or has been created locally - cd "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" + cd "$DOCKER_IMAGES_REPO" + assert_cwd_is_repo "$DOCKER_IMAGES_REPO" + assert_clean_index "$DOCKER_IMAGES_REPO" - # the PR branch should already exist + # the release branch should already exist git switch "$RELEASE_BRANCH" + assert_on_branch "$RELEASE_BRANCH" if $PUSH; then git pull else git pull || echo "Dry-run: remote branch doesn't exist yet..." # NOTE (@NickLarsenNZ): We could add a fake commit, but that would poison the current state. fi + assert_on_branch "$RELEASE_BRANCH" + # TODO: Assert we are in sync with the remote release branch (0 ahead, 0 behind after pull) git tag -sm "release $RELEASE_TAG" "$RELEASE_TAG" + assert_remote_exists "$REMOTE" "$DOCKER_IMAGES_REPO" push_branch -} +) -tag_operators() { - while IFS="" read -r operator || [ -n "$operator" ]; do - cd "${TEMP_RELEASE_FOLDER}/${operator}" - git switch "$RELEASE_BRANCH" - if $PUSH; then - git pull - else - git pull || echo "Dry-run: remote branch doesn't exist yet..." - # NOTE (@NickLarsenNZ): We could add a fake commit, but that would poison the current state. - fi - git tag -sm "release $RELEASE_TAG" "$RELEASE_TAG" - push_branch - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) -} +# TODO: tag_operator and tag_products share the same logic. +# Extract the common tagging procedure into a shared function. +tag_operator() ( + local operator="$1" + cd "${operator}" + assert_cwd_is_repo "$operator" + assert_clean_index "$operator" -tag_repos() { - if [ "products" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - tag_products - fi - if [ "operators" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - tag_operators + git switch "$RELEASE_BRANCH" + assert_on_branch "$RELEASE_BRANCH" + if $PUSH; then + git pull + else + git pull || echo "Dry-run: remote branch doesn't exist yet..." + # NOTE (@NickLarsenNZ): We could add a fake commit, but that would poison the current state. fi -} - -check_tag_is_valid() { - git fetch --tags + assert_on_branch "$RELEASE_BRANCH" + # TODO: Assert we are in sync with the remote release branch (0 ahead, 0 behind after pull) + git tag -sm "release $RELEASE_TAG" "$RELEASE_TAG" + assert_remote_exists "$REMOTE" "$operator" + push_branch +) - # check tags: N.B. look for exact match - if git tag --list | grep -E "^$RELEASE_TAG\$"; then - >&2 echo "Tag $RELEASE_TAG already exists!" - exit 1 - fi +tag_repos() { + cd "$TEMP_RELEASE_FOLDER" + case "$WHAT" in + products) tag_products ;; + operators) for_each_operator tag_operator ;; + all) + tag_products + for_each_operator tag_operator + ;; + esac } -check_products() { - if [ ! -d "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" ]; then - echo "Cloning folder: $TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" - # $TEMP_RELEASE_FOLDER has already been created in main() - git clone "git@github.com:stackabletech/${DOCKER_IMAGES_REPO}.git" "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" - fi - cd "$TEMP_RELEASE_FOLDER/$DOCKER_IMAGES_REPO" - - # switch to the release branch, which should exist as tagging - # is subsequent to creating the branch. - BRANCH_EXISTS=$(git branch -a | grep -E "$RELEASE_BRANCH$") - - if [ -z "${BRANCH_EXISTS}" ]; then - >&2 echo "Expected release branch is missing: $RELEASE_BRANCH" - exit 1 - fi - - check_tag_is_valid -} -check_operators() { - while IFS="" read -r operator || [ -n "$operator" ]; do - echo "Operator: $operator" - if [ ! -d "$TEMP_RELEASE_FOLDER/${operator}" ]; then - echo "Cloning folder: $TEMP_RELEASE_FOLDER/${operator}" - # $TEMP_RELEASE_FOLDER has already been created in main() - git clone "git@github.com:stackabletech/${operator}.git" "$TEMP_RELEASE_FOLDER/${operator}" - - fi - cd "$TEMP_RELEASE_FOLDER/${operator}" - # Note, if this needs to check the branch exists locally, then use: - # "^[ *]*$RELEASE_BRANCH\$" - if ! git branch -a | grep -E "$RELEASE_BRANCH\$"; then - >&2 echo "Expected release branch is missing: ${operator}/$RELEASE_BRANCH" - exit 1 - fi - check_tag_is_valid - done < <(yq '... comments="" | .operators[] ' "$INITIAL_DIR"/release/config.yaml) -} +check_products() ( + ensure_clone "$DOCKER_IMAGES_REPO" + cd "$DOCKER_IMAGES_REPO" + assert_cwd_is_repo "$DOCKER_IMAGES_REPO" + assert_clean_index "$DOCKER_IMAGES_REPO" + # TODO (@NickLarsenNZ): Probably need a pull here + + # The release branch should exist (created in a prior step) + # NOTE: Do we need to check if the branch exists locally? + # Which branch should we be on here? Does it matter? + assert_remote_branch_exists "$REMOTE" "$RELEASE_BRANCH" + + assert_tag_not_exists "$REMOTE" "$RELEASE_TAG" +) + +check_operator() ( + local operator="$1" + echo "Operator: $operator" + ensure_clone "$operator" + cd "${operator}" + assert_cwd_is_repo "$operator" + assert_clean_index "$operator" + # TODO (@NickLarsenNZ): Probably need a pull here + + # The release branch should exist (created in a prior step) + # NOTE: Do we need to check if the branch exists locally? + # Which branch should we be on here? Does it matter? + assert_remote_branch_exists "$REMOTE" "$RELEASE_BRANCH" + assert_tag_not_exists "$REMOTE" "$RELEASE_TAG" +) checks() { - if [ "products" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - check_products - fi - if [ "operators" == "$WHAT" ] || [ "all" == "$WHAT" ]; then - check_operators - fi + cd "$TEMP_RELEASE_FOLDER" + case "$WHAT" in + products) check_products ;; + operators) for_each_operator check_operator ;; + all) + check_products + for_each_operator check_operator + ;; + esac } push_branch() { if $PUSH; then - echo "Pushing changes..." - git push "${REPOSITORY}" "${RELEASE_TAG}" + echo "Pushing tag..." + git push "${REMOTE}" "${RELEASE_TAG}" else - echo "Dry-run: not pushing..." - git push --dry-run "${REPOSITORY}" "${RELEASE_TAG}" + echo "Dry-run: not pushing tag..." + git push --dry-run "${REMOTE}" "${RELEASE_TAG}" fi } @@ -128,6 +129,7 @@ cleanup() { fi } +# TODO: Consider moving validation (validate_tag, validate_what) into parse_inputs parse_inputs() { RELEASE_TAG="" PUSH=false @@ -154,69 +156,30 @@ parse_inputs() { shift done - # remove leading and trailing quotes - RELEASE_TAG="${RELEASE_TAG%\"}" - RELEASE_TAG="${RELEASE_TAG#\"}" + RELEASE_TAG="$(strip_double_quotes "$RELEASE_TAG")" - # for a tag of e.g. 23.1.1, the release branch (already created) will be 23.1 - RELEASE="$(cut -d'.' -f1,2 <<< "$RELEASE_TAG")" - RELEASE_BRANCH="release-$RELEASE" INITIAL_DIR="$PWD" - DOCKER_IMAGES_REPO=$(yq '... comments="" | .images-repo ' "$INITIAL_DIR"/release/config.yaml) - TEMP_RELEASE_FOLDER="/tmp/stackable-$RELEASE_BRANCH" + derive_tag_vars "$RELEASE_TAG" echo "Settings: ${RELEASE_BRANCH}: Push: $PUSH: Cleanup: $CLEANUP" } check_dependencies() { - # check for a globally configured git user - if ! git_user=$(git config --global --includes --get user.name) \ - || ! git_email=$(git config --global --includes --get user.email); then - >&2 echo "Error: global git user name/email is not set." - exit 1 - else - echo "global git user: $git_user <$git_email>" - echo "Is this correct? (y/n)" - read -r response - if [[ "$response" == "y" || "$response" == "Y" ]]; then - echo "Proceeding with $git_user <$git_email>" - else - >&2 echo "User not accepted. Exiting." - exit 1 - fi - fi - - # check gh authentication: if this fails you will need to e.g. gh auth login - gh auth status - yq --version - python --version - cargo --version - cargo set-version --version - # check for jinja2-cli including pyyaml package - jinja2 --version - python -m pip show pyyaml + check_common_dependencies } main() { parse_inputs "$@" - # check if tag argument provided if [ -z "${RELEASE_TAG}" ]; then - >&2 echo "Usage: create-release-candidate-branch.sh -t <tag> [-p] [-c] [-w products|operators|all]" + >&2 echo "Usage: tag-release-candidate.sh -t <tag> [-p] [-c] [-w products|operators|all]" exit 1 fi - # check if argument matches our tag regex - if [[ ! $RELEASE_TAG =~ $TAG_REGEX ]]; then - >&2 echo "Provided tag [$RELEASE_TAG] does not match the required tag regex pattern [$TAG_REGEX]" - exit 1 - fi - - if [ ! -d "$TEMP_RELEASE_FOLDER" ]; then - echo "Creating folder for cloning docker images and operators: [$TEMP_RELEASE_FOLDER]" - mkdir -p "$TEMP_RELEASE_FOLDER" - fi + validate_tag "$RELEASE_TAG" + validate_what "$WHAT" "products" "operators" "all" + ensure_temp_folder check_dependencies # sanity checks before we start: folder, branches etc. diff --git a/release/test_common.bats b/release/test_common.bats new file mode 100644 index 0000000..a86bb58 --- /dev/null +++ b/release/test_common.bats @@ -0,0 +1,652 @@ +#!/usr/bin/env bats +# +# Tests for release/common.sh +# +# Run with: bats release/test_common.bats +# +# Note: bats is included in the nix shell, you can run `nix-shell --run zsh` to +# load dependencies. + +COMMON_SH="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)/common.sh" + +# Source common.sh in a subshell to avoid the direct-execution guard +# killing the test runner. BATS sets BASH_SOURCE[0] != $0, so the +# guard passes naturally when we source it. +setup() { + source "$COMMON_SH" + + # Ignore global/system git config so tests don't depend on the + # user's environment (e.g., GPG signing, aliases, etc.) + export GIT_CONFIG_GLOBAL=/dev/null + export GIT_CONFIG_NOSYSTEM=1 + export GIT_AUTHOR_NAME="Test" + export GIT_AUTHOR_EMAIL="test@test" + export GIT_COMMITTER_NAME="Test" + export GIT_COMMITTER_EMAIL="test@test" +} + +# --- for_each_operator --- + +@test "for_each_operator: calls function for each operator" { + INITIAL_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && cd .. && pwd)" + collected=() + collect_operator() { collected+=("$1"); } + for_each_operator collect_operator + [ "${#collected[@]}" -gt 0 ] + [[ " ${collected[*]} " == *" airflow-operator "* ]] +} + +@test "for_each_operator: passes extra arguments" { + INITIAL_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && cd .. && pwd)" + results=() + collect_with_extra() { results+=("$1:$2"); } + for_each_operator collect_with_extra "extra" + [[ " ${results[*]} " == *" airflow-operator:extra "* ]] +} + +# --- verify_release --- + +setup_verify_release_dir() { + VERIFY_DIR=$(mktemp -d) + # CHANGELOG with tag + printf "## [Unreleased]\n\n## [26.3.0] - 2026-05-20\n" > "$VERIFY_DIR/CHANGELOG.md" +} + +teardown_verify_release_dir() { + rm -rf "$VERIFY_DIR" +} + +@test "verify_release: passes with correct changelog" { + setup_verify_release_dir + run verify_release "$VERIFY_DIR" "26.3.0" "26.3" + [ "$status" -eq 0 ] + [[ "$output" == *"Verification passed"* ]] + teardown_verify_release_dir +} + +@test "verify_release: fails when changelog missing tag" { + setup_verify_release_dir + echo "## [Unreleased]" > "$VERIFY_DIR/CHANGELOG.md" + run verify_release "$VERIFY_DIR" "26.3.0" "26.3" + [ "$status" -eq 1 ] + [[ "$output" == *"CHANGELOG.md does not contain"* ]] + teardown_verify_release_dir +} + +@test "verify_release: fails when antora version is wrong" { + setup_verify_release_dir + mkdir -p "$VERIFY_DIR/docs" + printf "version: \"99.9\"\nprerelease: false\n" > "$VERIFY_DIR/docs/antora.yml" + run verify_release "$VERIFY_DIR" "26.3.0" "26.3" + [ "$status" -eq 1 ] + [[ "$output" == *"antora.yml version is '99.9'"* ]] + teardown_verify_release_dir +} + +@test "verify_release: fails when antora prerelease is not false" { + setup_verify_release_dir + mkdir -p "$VERIFY_DIR/docs" + printf "version: \"26.3\"\nprerelease: true\n" > "$VERIFY_DIR/docs/antora.yml" + run verify_release "$VERIFY_DIR" "26.3.0" "26.3" + [ "$status" -eq 1 ] + [[ "$output" == *"antora.yml prerelease is 'true'"* ]] + teardown_verify_release_dir +} + +@test "verify_release: fails when nightly refs remain in docs" { + setup_verify_release_dir + mkdir -p "$VERIFY_DIR/docs" + echo "xref:nightly@home:index.adoc[]" > "$VERIFY_DIR/docs/test.adoc" + run verify_release "$VERIFY_DIR" "26.3.0" "26.3" + [ "$status" -eq 1 ] + [[ "$output" == *"nightly@"* ]] + teardown_verify_release_dir +} + +# --- update_changelog --- + +@test "update_changelog: inserts tag entry" { + local tmpdir + tmpdir=$(mktemp -d) + echo "## [Unreleased]" > "$tmpdir/CHANGELOG.md" + update_changelog "$tmpdir/CHANGELOG.md" "26.3.0" + grep -q "## \[26.3.0\]" "$tmpdir/CHANGELOG.md" + grep -q "## \[Unreleased\]" "$tmpdir/CHANGELOG.md" + rm -rf "$tmpdir" +} + +@test "update_changelog: skips if tag already present" { + local tmpdir + tmpdir=$(mktemp -d) + printf "## [Unreleased]\n\n## [26.3.0] - 2026-05-20\n" > "$tmpdir/CHANGELOG.md" + run update_changelog "$tmpdir/CHANGELOG.md" "26.3.0" + [ "$status" -eq 0 ] + [[ "$output" == *"already contains"* ]] + rm -rf "$tmpdir" +} + +@test "update_changelog: does not match partial tags" { + local tmpdir + tmpdir=$(mktemp -d) + printf "## [Unreleased]\n\n## [26.3.0-rc1] - 2026-05-20\n" > "$tmpdir/CHANGELOG.md" + update_changelog "$tmpdir/CHANGELOG.md" "26.3.0" + grep -q "## \[26.3.0\]" "$tmpdir/CHANGELOG.md" + grep -q "## \[26.3.0-rc1\]" "$tmpdir/CHANGELOG.md" + rm -rf "$tmpdir" +} + +@test "update_changelog: rejects invalid tag" { + local tmpdir + tmpdir=$(mktemp -d) + echo "## [Unreleased]" > "$tmpdir/CHANGELOG.md" + run update_changelog "$tmpdir/CHANGELOG.md" "26.3" + [ "$status" -eq 1 ] + [[ "$output" == *"does not match CalVer format"* ]] + rm -rf "$tmpdir" +} + +# --- ensure_temp_folder --- + +@test "ensure_temp_folder: creates folder if missing" { + TEMP_RELEASE_FOLDER=$(mktemp -d)/test-release + run ensure_temp_folder + [ "$status" -eq 0 ] + [ -d "$TEMP_RELEASE_FOLDER" ] + rm -rf "$(dirname "$TEMP_RELEASE_FOLDER")" +} + +@test "ensure_temp_folder: skips if folder exists" { + TEMP_RELEASE_FOLDER=$(mktemp -d) + run ensure_temp_folder + [ "$status" -eq 0 ] + [[ "$output" != *"Creating"* ]] + rm -rf "$TEMP_RELEASE_FOLDER" +} + +# --- ensure_clone --- + +@test "ensure_clone: skips if directory exists" { + local tmpdir + tmpdir=$(mktemp -d) + cd "$tmpdir" + mkdir "my-repo" + run ensure_clone "my-repo" + [ "$status" -eq 0 ] + [[ "$output" != *"Cloning"* ]] + rm -rf "$tmpdir" +} + +# --- strip_double_quotes --- + +@test "strip_double_quotes: removes surrounding quotes" { + run strip_double_quotes '"hello"' + [ "$output" == 'hello' ] +} + +@test "strip_double_quotes: leaves unquoted string unchanged" { + run strip_double_quotes 'hello' + [ "$output" == 'hello' ] +} + +@test "strip_double_quotes: handles empty string" { + run strip_double_quotes '' + [ "$output" == '' ] +} + +# --- validate_what --- + +@test "validate_what: accepts valid value" { + run validate_what "products" "products" "operators" "all" + [ "$status" -eq 0 ] +} + +@test "validate_what: accepts last valid value" { + run validate_what "all" "products" "operators" "all" + [ "$status" -eq 0 ] +} + +@test "validate_what: rejects invalid value" { + run validate_what "prodcts" "products" "operators" "all" + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid -w/--what value: 'prodcts'"* ]] +} + +@test "validate_what: rejects value not in allowed set for this script" { + run validate_what "demos" "products" "operators" "all" + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid -w/--what value: 'demos'"* ]] +} + +@test "validate_what: rejects empty value" { + run validate_what "" "products" "operators" "all" + [ "$status" -eq 1 ] + [[ "$output" == *"-w/--what is required"* ]] +} + +# --- validate_tag --- + +@test "validate_tag: accepts final release tag" { + run validate_tag "26.3.0" + [ "$status" -eq 0 ] +} + +@test "validate_tag: accepts RC tag" { + run validate_tag "26.3.0-rc1" + [ "$status" -eq 0 ] +} + +@test "validate_tag: accepts multi-digit RC" { + run validate_tag "25.11.1-rc12" + [ "$status" -eq 0 ] +} + +@test "validate_tag: accepts month 12" { + run validate_tag "25.12.0" + [ "$status" -eq 0 ] +} + +@test "validate_tag: rejects month 0" { + run validate_tag "25.0.0" + [ "$status" -eq 1 ] + [[ "$output" == *"does not match CalVer format"* ]] +} + +@test "validate_tag: rejects month 13" { + run validate_tag "25.13.0" + [ "$status" -eq 1 ] +} + +@test "validate_tag: rejects leading zero on month" { + run validate_tag "25.03.0" + [ "$status" -eq 1 ] +} + +@test "validate_tag: rejects missing patch" { + run validate_tag "26.3" + [ "$status" -eq 1 ] +} + +@test "validate_tag: rejects empty tag" { + run validate_tag "" + [ "$status" -eq 1 ] + [[ "$output" == *"release tag is required"* ]] +} + +@test "validate_tag: rejects missing hyphen in rc" { + run validate_tag "26.3.0rc1" + [ "$status" -eq 1 ] +} + +@test "validate_tag: --no-rc accepts final release" { + run validate_tag --no-rc "26.3.0" + [ "$status" -eq 0 ] +} + +@test "validate_tag: --no-rc rejects RC tag" { + run validate_tag --no-rc "26.3.0-rc1" + [ "$status" -eq 1 ] + [[ "$output" == *"only for final releases"* ]] +} + +@test "validate_tag: rejects unknown flag" { + run validate_tag --no-r "26.3.0" + [ "$status" -eq 1 ] + [[ "$output" == *"unknown flag '--no-r'"* ]] +} + +@test "validate_tag: rejects trailing arguments" { + run validate_tag "26.3.0" "--no-rc" + [ "$status" -eq 1 ] + [[ "$output" == *"unexpected trailing arguments"* ]] +} + +# --- validate_release_base_version --- + +@test "validate_release_base_version: accepts valid version" { + run validate_release_base_version "26.3" + [ "$status" -eq 0 ] +} + +@test "validate_release_base_version: accepts month 11" { + run validate_release_base_version "25.11" + [ "$status" -eq 0 ] +} + +@test "validate_release_base_version: rejects version with patch" { + run validate_release_base_version "26.3.0" + [ "$status" -eq 1 ] +} + +@test "validate_release_base_version: rejects empty" { + run validate_release_base_version "" + [ "$status" -eq 1 ] + [[ "$output" == *"release version is required"* ]] +} + +@test "validate_release_base_version: rejects month 0" { + run validate_release_base_version "26.0" + [ "$status" -eq 1 ] +} + +@test "validate_release_base_version: rejects leading zero" { + run validate_release_base_version "26.03" + [ "$status" -eq 1 ] +} + +# --- derive_tag_vars --- + +@test "derive_tag_vars: sets all variables from a final tag" { + INITIAL_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && cd .. && pwd)" + derive_tag_vars "26.3.0" + [ "$RELEASE_BASE" == "26.3" ] + [ "$RELEASE_BRANCH" == "release-26.3" ] + [ "$PR_BRANCH" == "pr-26.3.0" ] + [ "$TEMP_RELEASE_FOLDER" == "/tmp/stackable-release-26.3" ] + [ -n "$DOCKER_IMAGES_REPO" ] +} + +@test "derive_tag_vars: sets all variables from an RC tag" { + INITIAL_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && cd .. && pwd)" + derive_tag_vars "25.11.1-rc2" + [ "$RELEASE_BASE" == "25.11" ] + [ "$RELEASE_BRANCH" == "release-25.11" ] + [ "$PR_BRANCH" == "pr-25.11.1-rc2" ] + [ "$TEMP_RELEASE_FOLDER" == "/tmp/stackable-release-25.11" ] +} + +# --- derive_branch_vars --- + +@test "derive_branch_vars: sets all variables" { + INITIAL_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && cd .. && pwd)" + derive_branch_vars "26.3" + [ "$RELEASE_BRANCH" == "release-26.3" ] + [ "$TEMP_RELEASE_FOLDER" == "/tmp/stackable-release-26.3" ] + [ -n "$DOCKER_IMAGES_REPO" ] + [ -n "$DEMOS_REPO" ] +} + +# --- assert_cwd_is_repo (needs temp git repo) --- + +setup_temp_repo() { + TEST_REPO=$(mktemp -d) + git -C "$TEST_REPO" init -b main --quiet + git -C "$TEST_REPO" commit --allow-empty -m "init" --quiet +} + +teardown_temp_repo() { + rm -rf "$TEST_REPO" +} + +@test "assert_cwd_is_repo: passes in a git repo" { + setup_temp_repo + cd "$TEST_REPO" + run assert_cwd_is_repo + [ "$status" -eq 0 ] + teardown_temp_repo +} + +@test "assert_cwd_is_repo: passes with matching name" { + setup_temp_repo + # Rename dir to simulate a known repo name + local named_repo="${TEST_REPO}-airflow-operator" + mv "$TEST_REPO" "$named_repo" + TEST_REPO="$named_repo" + cd "$named_repo" + run assert_cwd_is_repo "$(basename "$named_repo")" + [ "$status" -eq 0 ] + teardown_temp_repo +} + +@test "assert_cwd_is_repo: fails with wrong name" { + setup_temp_repo + cd "$TEST_REPO" + run assert_cwd_is_repo "wrong-name" + [ "$status" -eq 1 ] + [[ "$output" == *"expected to be in repo 'wrong-name'"* ]] + teardown_temp_repo +} + +@test "assert_cwd_is_repo: fails outside a git repo" { + cd /tmp + run assert_cwd_is_repo + [ "$status" -eq 1 ] + [[ "$output" == *"not inside a git repository"* ]] +} + +# --- assert_on_branch (needs temp git repo) --- + +@test "assert_on_branch: passes on correct branch" { + setup_temp_repo + cd "$TEST_REPO" + git checkout -b "release-26.3" --quiet + run assert_on_branch "release-26.3" + [ "$status" -eq 0 ] + teardown_temp_repo +} + +@test "assert_on_branch: fails on wrong branch" { + setup_temp_repo + cd "$TEST_REPO" + run assert_on_branch "release-26.3" + [ "$status" -eq 1 ] + [[ "$output" == *"expected to be on branch 'release-26.3', but currently on 'main'"* ]] + teardown_temp_repo +} + +@test "assert_on_branch: fails with empty argument" { + run assert_on_branch "" + [ "$status" -eq 1 ] + [[ "$output" == *"expected branch name is required"* ]] +} + +# --- assert_remote_exists (needs temp git repo with remote) --- + +setup_temp_repo_with_remote() { + setup_temp_repo + cd "$TEST_REPO" +} + +@test "assert_remote_exists: passes with SSH URL" { + setup_temp_repo_with_remote + git remote add origin "git@github.com:stackabletech/airflow-operator.git" + run assert_remote_exists "origin" "airflow-operator" + [ "$status" -eq 0 ] + teardown_temp_repo +} + +@test "assert_remote_exists: passes with HTTPS URL" { + setup_temp_repo_with_remote + git remote add origin "https://github.com/stackabletech/airflow-operator.git" + run assert_remote_exists "origin" "airflow-operator" + [ "$status" -eq 0 ] + teardown_temp_repo +} + +@test "assert_remote_exists: passes without .git suffix" { + setup_temp_repo_with_remote + git remote add origin "git@github.com:stackabletech/airflow-operator" + run assert_remote_exists "origin" "airflow-operator" + [ "$status" -eq 0 ] + teardown_temp_repo +} + +@test "assert_remote_exists: fails with wrong repo name" { + setup_temp_repo_with_remote + git remote add origin "git@github.com:stackabletech/druid-operator.git" + run assert_remote_exists "origin" "airflow-operator" + [ "$status" -eq 1 ] + [[ "$output" == *"expected github.com/stackabletech/airflow-operator"* ]] + teardown_temp_repo +} + +@test "assert_remote_exists: fails with wrong org" { + setup_temp_repo_with_remote + git remote add origin "git@github.com:someoneelse/airflow-operator.git" + run assert_remote_exists "origin" "airflow-operator" + [ "$status" -eq 1 ] + teardown_temp_repo +} + +@test "assert_remote_exists: fails with nonexistent remote" { + setup_temp_repo_with_remote + run assert_remote_exists "upstream" "airflow-operator" + [ "$status" -eq 1 ] + [[ "$output" == *"does not exist"* ]] + teardown_temp_repo +} + +# --- assert_tag_not_exists --- + +@test "assert_tag_not_exists: passes when tag does not exist" { + setup_temp_repo_with_remote_branch + run assert_tag_not_exists "origin" "26.3.0" + [ "$status" -eq 0 ] + teardown_temp_repo_with_remote_branch +} + +@test "assert_tag_not_exists: fails when tag exists on remote" { + setup_temp_repo_with_remote_branch + git tag "26.3.0" + git push origin "26.3.0" --quiet + run assert_tag_not_exists "origin" "26.3.0" + [ "$status" -eq 1 ] + [[ "$output" == *"already exists"* ]] + teardown_temp_repo_with_remote_branch +} + +@test "assert_tag_not_exists: does not match partial tag names" { + setup_temp_repo_with_remote_branch + git tag "26.3.0-rc1" + git push origin "26.3.0-rc1" --quiet + run assert_tag_not_exists "origin" "26.3.0" + [ "$status" -eq 0 ] + teardown_temp_repo_with_remote_branch +} + +# --- remote_branch_exists / assert_remote_branch_exists / assert_remote_branch_not_exists --- + +setup_temp_repo_with_remote_branch() { + # Create a bare remote repo and a local clone with a branch + TEST_REMOTE=$(mktemp -d) + git -C "$TEST_REMOTE" init --bare --quiet + TEST_REPO=$(mktemp -d) + git clone "$TEST_REMOTE" "$TEST_REPO" --quiet + cd "$TEST_REPO" + git commit --allow-empty -m "init" --quiet + git push --quiet + git checkout -b "release-26.3" --quiet + git push -u origin "release-26.3" --quiet +} + +teardown_temp_repo_with_remote_branch() { + rm -rf "$TEST_REPO" "$TEST_REMOTE" +} + +@test "remote_branch_exists: returns 0 for existing branch" { + setup_temp_repo_with_remote_branch + run remote_branch_exists "origin" "release-26.3" + [ "$status" -eq 0 ] + teardown_temp_repo_with_remote_branch +} + +@test "remote_branch_exists: returns 2 for missing branch" { + setup_temp_repo_with_remote_branch + run remote_branch_exists "origin" "release-99.9" + [ "$status" -eq 2 ] + teardown_temp_repo_with_remote_branch +} + +@test "remote_branch_exists: does not match partial names" { + setup_temp_repo_with_remote_branch + run remote_branch_exists "origin" "release-26" + [ "$status" -eq 2 ] + teardown_temp_repo_with_remote_branch +} + +@test "assert_remote_branch_exists: passes for existing branch" { + setup_temp_repo_with_remote_branch + run assert_remote_branch_exists "origin" "release-26.3" + [ "$status" -eq 0 ] + teardown_temp_repo_with_remote_branch +} + +@test "assert_remote_branch_exists: fails for missing branch" { + setup_temp_repo_with_remote_branch + run assert_remote_branch_exists "origin" "release-99.9" + [ "$status" -eq 1 ] + [[ "$output" == *"does not exist on remote"* ]] + teardown_temp_repo_with_remote_branch +} + +@test "assert_remote_branch_not_exists: passes for missing branch" { + setup_temp_repo_with_remote_branch + run assert_remote_branch_not_exists "origin" "pr-26.3.0-rc1" + [ "$status" -eq 0 ] + teardown_temp_repo_with_remote_branch +} + +@test "assert_remote_branch_not_exists: fails for existing branch" { + setup_temp_repo_with_remote_branch + run assert_remote_branch_not_exists "origin" "release-26.3" + [ "$status" -eq 1 ] + [[ "$output" == *"already exists on remote"* ]] + teardown_temp_repo_with_remote_branch +} + +# --- assert_clean_index (needs temp git repo) --- + +@test "assert_clean_index: passes on clean repo" { + setup_temp_repo + cd "$TEST_REPO" + run assert_clean_index + [ "$status" -eq 0 ] + teardown_temp_repo +} + +@test "assert_clean_index: fails on staged changes" { + setup_temp_repo + cd "$TEST_REPO" + echo "change" > tracked_file.txt + git add tracked_file.txt + git commit -m "add file" --quiet + echo "modified" > tracked_file.txt + git add tracked_file.txt + run assert_clean_index + [ "$status" -eq 1 ] + [[ "$output" == *"dirty git index"* ]] + teardown_temp_repo +} + +@test "assert_clean_index: fails on unstaged changes" { + setup_temp_repo + cd "$TEST_REPO" + echo "change" > tracked_file.txt + git add tracked_file.txt + git commit -m "add file" --quiet + echo "modified" > tracked_file.txt + run assert_clean_index + [ "$status" -eq 1 ] + [[ "$output" == *"dirty git index"* ]] + teardown_temp_repo +} + +@test "assert_clean_index: warns on untracked files, continues with y" { + setup_temp_repo + cd "$TEST_REPO" + echo "new" > untracked_file.txt + run bash -c 'source "'"$COMMON_SH"'" && echo y | assert_clean_index' + [ "$status" -eq 0 ] + [[ "$output" == *"untracked files found"* ]] + [[ "$output" == *"untracked_file.txt"* ]] + teardown_temp_repo +} + +@test "assert_clean_index: warns on untracked files, aborts with n" { + setup_temp_repo + cd "$TEST_REPO" + echo "new" > untracked_file.txt + run bash -c 'source "'"$COMMON_SH"'" && echo n | assert_clean_index' + [ "$status" -eq 1 ] + [[ "$output" == *"Aborting due to untracked files"* ]] + teardown_temp_repo +} diff --git a/shell.nix b/shell.nix index 4883b4f..c9ea341 100644 --- a/shell.nix +++ b/shell.nix @@ -10,5 +10,6 @@ pkgs.mkShell { yq-go python311Packages.pip python311Packages.pyyaml + bats # for shell function testing ]; }