Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CI/CD pipeline for automated releases #35

Merged
merged 5 commits into from
Apr 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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