From ead76acb00dbce935b91f09c5716401dfa121979 Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Sun, 7 Apr 2024 13:10:37 -0500 Subject: [PATCH] ci: Make release tag when version is bumped --- .github/check-if-tag-already-exists.sh | 197 ++++++++++++++++++ .github/parse-semver-version.sh | 58 ++++++ .../validate-version-is-semver-compliant.sh | 74 +++++++ .github/workflows/release.yml | 39 +++- 4 files changed, 367 insertions(+), 1 deletion(-) create mode 100755 .github/check-if-tag-already-exists.sh create mode 100755 .github/parse-semver-version.sh create mode 100755 .github/validate-version-is-semver-compliant.sh diff --git a/.github/check-if-tag-already-exists.sh b/.github/check-if-tag-already-exists.sh new file mode 100755 index 0000000..49d35af --- /dev/null +++ b/.github/check-if-tag-already-exists.sh @@ -0,0 +1,197 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset +set -o noclobber + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) + +usage() { + cat < + +Check if a git tag exists in the current repo that should prevent creating a +specific new tag. + +--help, -h Show this help and exit +--tag-prefix, -t Tag prefix. Defaults to 'v' +--branch-prefix, -b Release branch prefix. Defaults to 'release/' +--target-branch Target branch. Defaults to 'master' + +The following checks are performed +* A tag which is equivalent to the target version does not exist +* A non-prerelease tag that matches the target version does not already exist +* A separate release branch does not exist for the major.minor of the target + version + +A semantic version is defined as equivalent if the major, minor, patch +and prerelease information are all the same. This means that two versions may +not be differentiated by only the build metadata, regardless of if only one of +the versions being compared contains it. + +Exits with non-zero if you should not be allowed to create the target version +EOF +} + +check_if_tag_already_exists() { + local -n components="$1" + local tag_prefix="$2" + + # If a tag matching the prerelease and/or metadata exactly is found, then don't allow + # + # If a tag with prerelease and/or metadata is found, but our current version has a different + # prerelease/metadata component, allow it + # + # If there is no prerelease/metadata and the tag matches exactly, then don't allow + # + # Allowed: + # Existing | New + # --------------|----------- + # v1.0.0 | v0.0.1 + # v1.0.0 | v0.0.0-rc1 + # v1.0.0-rc1 | v1.0.0-rc2 + # v1.0.0-rc1+g1 | v1.0.0-rc2+g1 + # + # Disallowed: + # Existing | New + # -------------|------------ + # v1.0.0 | v1.0.0 + # v1.0.0-rc1 | v1.0.0-rc1 + # v1.0.0+g1234 | v1.0.0+gabcd + + # If you're making a prerelease, check to see that a "real" release hasn't been made, and that + # no duplicate prerelease has been made + if [[ -n "${components[prerelease]}" ]]; then + local pattern="${tag_prefix}${components[major]}.${components[minor]}.${components[patch]}-${components[prerelease]}" + local tags + tags="$(git for-each-ref --format='%(refname:short)' "refs/tags/${pattern}*")" + if [[ -n "$tags" ]]; then + echo "Found existing prerelease tags:" >&2 + for tag in $tags; do + echo " $tag" >&2 + done + exit 1 + fi + + # Also check if there's a "real" release for that version + pattern="${tag_prefix}${components[major]}.${components[minor]}.${components[patch]}" + tags="$(git for-each-ref --format='%(refname:short)' "refs/tags/${pattern}")" + if [[ -n "$tags" ]]; then + echo "You tried to make a prerelease of a version that was already released:" >&2 + for tag in $tags; do + echo " $tag" >&2 + done + exit 1 + fi + tags="$(git for-each-ref --format='%(refname:short)' "refs/tags/${pattern}+*")" + if [[ -n "$tags" ]]; then + echo "You tried to make a prerelease of a version that was already released:" >&2 + for tag in $tags; do + echo " $tag" >&2 + done + exit 1 + fi + # If there's no prerelease, we can't even re-use the same major.minor.patch, even if the build + # metadata is different + else + local pattern="${tag_prefix}${components[major]}.${components[minor]}.${components[patch]}" + local tags + tags="$(git for-each-ref --format='%(refname:short)' "refs/tags/${pattern}")" + if [[ -n "$tags" ]]; then + echo "Found existing release tags:" >&2 + for tag in $tags; do + echo " $tag" >&2 + done + exit 1 + fi + tags="$(git for-each-ref --format='%(refname:short)' "refs/tags/${pattern}+*")" + if [[ -n "$tags" ]]; then + echo "Found existing release tag with metadata:" >&2 + for tag in $tags; do + echo " $tag" >&2 + done + exit 1 + fi + fi + +} + +check_if_maintenance_release_made_on_non_maintenance_branch() { + local -n components="$1" + local branch_prefix="$2" + local target_branch="$3" + + local major_minor="${components[major]}.${components[minor]}" + + # Has there been a release branch for the specified version been made? + local branches + branches="$(git for-each-ref --format='%(refname:short)' "refs/heads/${branch_prefix}${major_minor}*")" + if [[ -n "$branches" ]]; then + echo "Found release branch for $major_minor: ${branches}" + for branch in $branches; do + if [[ "$branch" = "$target_branch" ]]; then + echo "...but that branch was the target branch, so that's okay." + return 0 + fi + done + echo "If a release branch has been made for $major_minor, you can't release a new $major_minor version unless done from that release branch" + exit 1 + fi +} + +main() { + local version="" + local tag_prefix="v" + local branch_prefix="release/" + local target_branch="main" + + while test $# -gt 0; do + case "$1" in + --help | -h) + usage + exit + ;; + --tag-prefix | -t) + tag_prefix="$2" + shift + ;; + --branch-prefix | -b) + branch_prefix="$2" + shift + ;; + --target-branch) + target_branch="$2" + shift + ;; + *) + if [[ -z "$version" ]]; then + version="$1" + else + echo "Unexpected argument '$1'" >&2 + exit 1 + fi + ;; + esac + shift + done + + if [[ -z "$version" ]]; then + echo "Missing required argument" >&2 + exit 1 + fi + + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/parse-semver-version.sh" + + # shellcheck disable=SC2034 + # nameref array passing confuses shellcheck + local -A version_components + # Exits the script if it fails to parse the version components + get_tag_semver_components "$version" version_components + + check_if_tag_already_exists version_components "$tag_prefix" + check_if_maintenance_release_made_on_non_maintenance_branch version_components "$branch_prefix" "$target_branch" +} + +main "$@" diff --git a/.github/parse-semver-version.sh b/.github/parse-semver-version.sh new file mode 100755 index 0000000..05f26e7 --- /dev/null +++ b/.github/parse-semver-version.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# shellcheck disable=SC2154,SC2034 + +# Function to parse the components of a semantic version string with optional +# prefix. +# +# Params: +# $1 - The string to parse +# $2 - The name of the variable to store the output in +# +# Returns: +# The resulting components are stored in an associative array named according +# to the second argument passed to the function. (See usage example) +# +# Exits with code 1 if the given text cannot be parsed +# +# Usage Example: +# +# source ./parse-semver-version.sh +# +# local -A tag_components +# get_tag_semver_components v1.2.3-a+b tag_components +# +# echo "$tag_components[prefix]" +# echo "$tag_components[major].$tag_components[minor].$tag_components[patch]" +# echo "$tag_components[prerelease] -- $tag_components[buildmetadata]" +get_tag_semver_components() { + + local tag="$1" + # This is a "nameref" variable. $2 holds the name of the variable to update. + # We declare it as an associative array in the calling function. + local -n components="$2" + + # Taken from https://gist.github.com/rverst/1f0b97da3cbeb7d93f4986df6e8e5695, which itself is a + # form of the PCRE regex from https://semver.org modified to work with Bash regular expressions, + # and to allow an optional prefix for v1.0.0 style Git tags. + # + # Use the string "v1.2.3-0.ab.0001a.a+gabcd.xyz" to test. + # + # This parses the tag into the following capture groups: + # 1 2 3 4 5 67 8 9 10 11 12 + # (prefix )?(major ).(minor ).(patch )(-((prerelease )( ( )) ))?( +(buildmetadata( ) ))? + local regex='^([a-zA-Z_/-]+)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$' + + if [[ $tag =~ $regex ]]; then + components[prefix]="${BASH_REMATCH[1]}" + components[major]="${BASH_REMATCH[2]}" + components[minor]="${BASH_REMATCH[3]}" + components[patch]="${BASH_REMATCH[4]}" + components[prerelease]="${BASH_REMATCH[6]}" + components[buildmetadata]="${BASH_REMATCH[11]}" + else + echo "Failed to parse SemVer 2.0.0 tag '$tag'" >&2 + exit 1 + fi + + # The "components" array is "returned" via namerefs, similar to pass-by-reference in C++. +} diff --git a/.github/validate-version-is-semver-compliant.sh b/.github/validate-version-is-semver-compliant.sh new file mode 100755 index 0000000..8c6159f --- /dev/null +++ b/.github/validate-version-is-semver-compliant.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset +set -o noclobber + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) + +usage() { + cat < + +The script will exit with non-zero status if it fails to parse the given version number. + +Note - semver doesn't actually define any rules relating to a prefix; but we use pretty +frequently internally (e.g. making a tag like vx.y.z), so this script supports finding +a prefix before a valid semantic version. + +If the version does parse, then the different version components will be spit out in the following +format: + + $ $0 v2.3.1-rc2+g1234 + prefix: v + major: 2 + minor: 3 + patch: 1 + prerelease: rc2 + buildmetadata: g1234 +EOF +} + +main() { + local version="" + while test $# -gt 0; do + case "$1" in + --help | -h) + usage + exit + ;; + *) + if [[ -z "$version" ]]; then + version="$1" + else + echo "Unexpected argument '$1'" >&2 + exit 1 + fi + ;; + esac + shift + done + + if [[ -z "$version" ]]; then + echo "Missing required argument" >&2 + exit 1 + fi + + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/parse-semver-version.sh" + + # shellcheck disable=SC2034 + local -A version_components + # Exits the script if it fails to parse the version components + get_tag_semver_components "$version" version_components + + echo "prefix: ${version_components[prefix]}" + echo "major: ${version_components[major]}" + echo "minor: ${version_components[minor]}" + echo "patch: ${version_components[patch]}" + echo "prerelease: ${version_components[prerelease]}" + echo "buildmetadata: ${version_components[buildmetadata]}" +} + +main "$@" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 773b34e..64f4da2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,44 @@ name: Release on: push jobs: - release: + trigger-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate SemVer Version + shell: bash # Explicitly setting the shell sets errexit and pipefail flags + run: | + VERSION="$(.github/parse-manifest-key.sh Cargo.toml version)" + echo "Validating SemVer version '$VERSION' ..." + .github/validate-version-is-semver-compliant.sh "$VERSION" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Check if current version has a Git tag + id: if-tag-exists + shell: bash + run: |- + # Delete any local tags, and fetch remote tags to ensure we're up-to-date + git tag --list | xargs git tag --delete + git fetch --force --tags + + # Exits with success if tag does not exist, failure if tag _does_ exist. + .github/check-if-tag-already-exists.sh \ + --tag-prefix 'v' \ + --target-branch 'main' \ + "$VERSION" \ + ; + + - name: Skip release + if: steps.if-tag-exists.conclusion == 'failure' + run: echo "Found tag for version '$VERSION'. Skipping release ..." + + - name: Trigger release + if: steps.if-tag-exists.conclusion == 'success' + shell: bash + run: |- + echo "Could not find tag for current version '$VERSION'. Making release ..." + + release-notes: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4