Skip to content

Commit

Permalink
ci: Make release tag when version is bumped
Browse files Browse the repository at this point in the history
  • Loading branch information
Notgnoshi committed Apr 7, 2024
1 parent f1fcafb commit eafb750
Show file tree
Hide file tree
Showing 4 changed files with 375 additions and 1 deletion.
197 changes: 197 additions & 0 deletions .github/check-if-tag-already-exists.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
Usage: $0 [--help] [--tag-prefix TAG] [--branch-prefix BRANCH] <VERSION>
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 <VERSION> 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 "$@"
58 changes: 58 additions & 0 deletions .github/parse-semver-version.sh
Original file line number Diff line number Diff line change
@@ -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++.
}
74 changes: 74 additions & 0 deletions .github/validate-version-is-semver-compliant.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
Usage: $0 [--help] <VERSION>
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 <VERSION> 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 "$@"
47 changes: 46 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,54 @@
name: Release

# This workflow is not ordered after the 'Lint' workflow. And doing so is difficult, because
# workflow_run: only applies on the default branch, and I EXPRESSLY WANT this workflow to run in PRs
# too, because it validates some preconditions for making the release.
#
# So make the assumption that if the PR pipeline passed, and this workflow runs on the default
# branch, it's okay to make a release without waiting for the build and test
on: push

jobs:
release:
trigger-release:
needs: on-success
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-tags: true
- 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: |-
# 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'
# TODO: Restrict this to main or release branches
shell: bash
run: |-
echo "Could not find tag for current version '$VERSION'. Making release ..."
# Run all jobs except "Make The Release" on every push event
release-notes:
needs: on-success
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down

0 comments on commit eafb750

Please sign in to comment.