Skip to content

Commit

Permalink
Add CI/CD pipeline for automated releases (#35)
Browse files Browse the repository at this point in the history
Closes @28.
  • Loading branch information
Notgnoshi authored Apr 7, 2024
2 parents e6df694 + e5ca342 commit d9ebf5f
Show file tree
Hide file tree
Showing 12 changed files with 586 additions and 21 deletions.
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 "$@"
100 changes: 100 additions & 0 deletions .github/get-release-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
set -o noclobber

RED="\033[31m"
GREEN="\033[32m"
BLUE="\033[34m"
RESET="\033[0m"

debug() {
echo -e "${BLUE}DEBUG:${RESET} $*" >&2
}

info() {
echo -e "${GREEN}INFO:${RESET} $*" >&2
}

error() {
echo -e "${RED}ERROR:${RESET} $*" >&2
}

usage() {
echo "Usage: $0 [--help] <VERSION> <DESCRIPTION>"
echo
echo "Positional Arguments:"
echo
echo " <VERSION> The version for which to generate the release notes"
echo " <DESCRIPTION> The project description, because every release notes should describe"
echo " what the project is for new users"
echo
echo "Options:"
echo
echo " --help, -h Show this help and exit"
}

parse_changelog() {
local -r changelog="$1"
local -r version="$2"

# 0,/pat1/d deletes from line 1 to pat1 inclusive
# /pat2/Q exits without printing on the first line to match pat2
sed "0,/^# Herostratus - $version/d;/^# /Q" "$changelog"
}

main() {
local version=""
local description=""

while [[ $# -gt 0 ]]; do
case "$1" in
--help | -h)
usage
exit 0
;;
-*)
error "Unexpected option: $1"
usage >&2
exit 1
;;
*)
if [[ -z "$version" ]]; then
version="$1"
elif [[ -z "$description" ]]; then
description="$1"
fi
;;
esac
shift
done

if [[ -z "$version" ]]; then
error "Missing required <VERSION> positional argument"
exit 1
elif [[ -z "$description" ]]; then
error "Missing required <DESCRIPTION> positional argument"
exit 1
fi

local repo_dir
repo_dir=$(git rev-parse --show-toplevel)
local -r changelog="$repo_dir/CHANGELOG.md"
if [[ ! -f "$changelog" ]]; then
error "Could not find '$changelog'"
exit 1
fi

# This outputs the version header to stdout
if ! grep "^# Herostratus - $version" "$changelog"; then
error "Could not find version '$version' in '$changelog'"
exit 1
fi
# Add the project description to the release notes
echo "$description"
# This outputs from the version header (exclusive) to the next version header (exclusive)
parse_changelog "$changelog" "$version"
}

main "$@"
15 changes: 15 additions & 0 deletions .github/parse-manifest-key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
set -o noclobber

CARGO_MANIFEST="${1:-Cargo.toml}"
MANIFEST_KEY="${2:-version}"

cargo metadata \
--format-version=1 \
--manifest-path "$CARGO_MANIFEST" \
--no-deps |
jq ".packages[0].$MANIFEST_KEY" |
tr -d '"'
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++.
}
Loading

0 comments on commit d9ebf5f

Please sign in to comment.