Skip to content

Instantly share code, notes, and snippets.

@rmpel
Created April 10, 2025 09:30
Show Gist options
  • Save rmpel/ec3b6e8b92a3d64a25c5d270a5e9b605 to your computer and use it in GitHub Desktop.
Save rmpel/ec3b6e8b92a3d64a25c5d270a5e9b605 to your computer and use it in GitHub Desktop.
Build a changelog from the previous tag to head in a composer based website
#!/bin/bash
# A script to generate a changelog by comparing composer packages and project commits.
#
# Disclaimer:
# - Using this script is at your own risk. This script only uses READ operations, but I still
# do not take any responsibility.
#
# License:
# - BSD 2-Clause — Free to use with attribution. No warranty provided.
#
# Requirements:
# - Must be run from within the git repository
# - Assumes the default branch is named 'master' or 'main'
# - Requires composer to be installed and available in PATH
# - Uses git to obtain tags and commit messages
# - Requires `git`, `jq`, `awk`, `diff`
# - Supports Bash 3.2 on MacOS, should support Bash 4.x on any *nix system, but not tested.
#
# Usage:
# just run the script inside a root-project clone directory.
# A file final_changelog.txt will remain after succesful execution.
# Exit on error and treat unset variables as errors.
set -euo pipefail
# -----------------------------
# Step 1: Verify Environment
# -----------------------------
# Ensure we are on the master branch.
current_branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$current_branch" != "master" ] && [ "$current_branch" != "main" ]; then
echo "Error: You are on branch '$current_branch'. Switch to 'master' before running the script."
exit 1
fi
[ -f final_changelog.txt ] && rm final_changelog.txt || true
# Ensure that the working tree is clean.
if ! git diff-index --quiet HEAD --; then
echo "Error: Working tree is not clean. Please commit or stash your changes before proceeding."
exit 1
fi
# Determine if the running Bash supports associative arrays.
bash_major=$(echo "$BASH_VERSION" | cut -d. -f1)
if (( bash_major >= 4 )); then
USE_ASSOC=1
else
USE_ASSOC=0
fi
# Ensure that the local branch is up-to-date with remote.
echo "Fetching updates from remote..."
git fetch origin
LOCAL=$(git rev-parse @)
REMOTE=$(git rev-parse @{u})
BASE=$(git merge-base @ @{u})
if [ "$LOCAL" = "$REMOTE" ]; then
echo "Local branch is up-to-date with remote."
elif [ "$LOCAL" = "$BASE" ]; then
echo "Error: Your branch is behind remote. Please pull the latest changes."
exit 1
else
echo "Error: Local and remote branches have diverged. Please resolve this before proceeding."
exit 1
fi
# -----------------------------
# Step 2: Get Tags and Most Recent Tag
# -----------------------------
# List all tags (sorted by creation date).
echo "Retrieving latest tag from git..."
latest_tag=$(git describe --tags --abbrev=0)
echo "DEBUG: Most recent tag is '$latest_tag'"
# -----------------------------
# Step 3: Capture Composer Packages (Before Checkout)
# -----------------------------
# List installed composer packages and their revisions before ensuring HEAD is at master.
echo "Listing installed composer packages before confirming HEAD..."
git checkout $latest_tag
composer install
composer show > packages_before.txt
# -----------------------------
# Step 4: Confirm HEAD on master and Capture Composer Packages (After)
# -----------------------------
# Ensure we are at master HEAD (this is precautionary – you should already be there).
git checkout master
composer install
echo "Listing installed composer packages at HEAD..."
composer show > packages_after.txt
# -----------------------------
# Step 5: Build the List of Differences
# -----------------------------
echo "Building the differences between the package lists..."
# 'diff' returns non-zero if differences are found, so we use '|| true' to continue script execution.
diff packages_before.txt packages_after.txt > packages_diff.txt || true
# -----------------------------
# Step 6: List All Commit Messages for the Project
# -----------------------------
echo "Listing all project commit messages from tag '$latest_tag' to HEAD..."
git log --pretty=format:"%h - %s" ${latest_tag}..HEAD > project_commits.txt
# -----------------------------
# Step 7: Iterate over Changed Packages
# -----------------------------
## Generate package update log
## $1 = package name
## $2 = old version
## $3 = new version
package_update_log() {
local pkg="$1"
local old="$2"
local new="$3"
if [[ -z "$pkg" || -z "$old" || -z "$new" ]]; then
echo "Usage: package_update_log <package> <old-version> <new-version>" >&2
return 1
fi
local path
path=vendor/composer/$(jq -r '.packages[] | select(.name=="'$pkg'") | ."install-path"' vendor/composer/installed.json)
path=$(realpath $path)
echo $path $old $new >&2
if [[ -z "$path" || ! -d "$path/.git" ]]; then
echo "Package '$pkg' is not a git checkout, cannot determine changelog." >&2
else
echo "==> $pkg: $old → $new"
git -C "$path" log --oneline --no-merges "$old..$new"
fi
}
if [ "$USE_ASSOC" -eq 1 ]; then
# Bash 4+ : Use associative arrays.
declare -A old_versions
declare -A new_versions
# Read through packages_diff.txt and record old and new package information.
while IFS= read -r line; do
if [[ $line =~ ^\<\ ]]; then
# Remove the "< " prefix and extract package and version.
content="${line:2}"
pkg=$(echo "$content" | awk '{print $1}')
version=$(echo "$content" | awk '{
tag = $2
rev = ($3 ~ /^[0-9a-f]{7,}$/ ? $3 : "")
print (rev != "" ? rev : tag)
}')
old_versions["$pkg"]="$version"
elif [[ $line =~ ^\>\ ]]; then
content="${line:2}"
pkg=$(echo "$content" | awk '{print $1}')
version=$(echo "$content" | awk '{
tag = $2
rev = ($3 ~ /^[0-9a-f]{7,}$/ ? $3 : "")
print (rev != "" ? rev : tag)
}')
new_versions["$pkg"]="$version"
fi
done < packages_diff.txt
# Build a combined list of all packages that appear in either old or new.
declare -A all_packages
for pkg in "${!old_versions[@]}"; do
all_packages["$pkg"]=1
done
for pkg in "${!new_versions[@]}"; do
all_packages["$pkg"]=1
done
{
for pkg in "${!all_packages[@]}"; do
if [ -n "${old_versions[$pkg]:-}" ] && [ -n "${new_versions[$pkg]:-}" ]; then
echo "Package update for $pkg: from ${old_versions[$pkg]} to ${new_versions[$pkg]}"
package_update_log $pkg ${old_versions[$pkg]} ${new_versions[$pkg]}
elif [ -n "${old_versions[$pkg]:-}" ]; then
echo "Package removed: $pkg (was ${old_versions[$pkg]})"
elif [ -n "${new_versions[$pkg]:-}" ]; then
echo "Package added: $pkg (version ${new_versions[$pkg]})"
fi
done
} >> package_changelogs.txt
else
# Bash 3.x: Simulate associative arrays using paired indexed arrays.
old_keys=()
old_values=()
new_keys=()
new_values=()
# Read through packages_diff.txt and record old and new package information.
while IFS= read -r line; do
if [[ $line =~ ^\<\ ]]; then
content="${line:2}"
pkg=$(echo "$content" | awk '{print $1}')
version=$(echo "$content" | awk '{
tag = $2
rev = ($3 ~ /^[0-9a-f]{7,}$/ ? $3 : "")
print (rev != "" ? rev : tag)
}')
old_keys+=("$pkg")
old_values+=("$version")
elif [[ $line =~ ^\>\ ]]; then
content="${line:2}"
pkg=$(echo "$content" | awk '{print $1}')
version=$(echo "$content" | awk '{
tag = $2
rev = ($3 ~ /^[0-9a-f]{7,}$/ ? $3 : "")
print (rev != "" ? rev : tag)
}')
new_keys+=("$pkg")
new_values+=("$version")
fi
done < packages_diff.txt
# Helper function: retrieve value for a given package from a keys/values pair.
get_value() {
local key="$1"
shift
local -a keys=("${!1}")
local -a values=("${!2}")
local i
for i in "${!keys[@]}"; do
if [ "${keys[$i]}" = "$key" ]; then
echo "${values[$i]}"
return
fi
done
echo ""
}
# Build a combined unique list of all packages.
all_packages=()
for key in "${old_keys[@]}"; do
all_packages+=("$key")
done
for key in "${new_keys[@]}"; do
found=0
for existing in "${all_packages[@]}"; do
if [ "$existing" = "$key" ]; then
found=1
break
fi
done
if [ $found -eq 0 ]; then
all_packages+=("$key")
fi
done
{
for pkg in "${all_packages[@]}"; do
old_ver=$(get_value "$pkg" old_keys[@] old_values[@])
new_ver=$(get_value "$pkg" new_keys[@] new_values[@])
if [ -n "$old_ver" ] && [ -n "$new_ver" ]; then
echo "Package update for $pkg: from $old_ver to $new_ver"
package_update_log $pkg $old_ver $new_ver
elif [ -n "$old_ver" ]; then
echo "Package removed: $pkg (was $old_ver)"
elif [ -n "$new_ver" ]; then
echo "Package added: $pkg (version $new_ver)"
fi
done
} >> package_changelogs.txt
fi
# -----------------------------
# Step 8: Combine All Outputs to Final Changelog
# -----------------------------
final_changelog="final_changelog.txt"
{
echo "==============================="
echo "Project Changelog"
echo "From tag: $latest_tag"
echo "To HEAD on branch: master"
echo "Generated on: $(date)"
echo "==============================="
echo ""
echo "### Project Commit Messages"
cat project_commits.txt
echo ""
echo "### Plugins/Themes changes"
cat package_changelogs.txt
echo ""
} > "$final_changelog"
echo "Changelog generation complete. See '$final_changelog' for details."
# -----------------------------
# Step 9: Cleanup Intermediate Files
# -----------------------------
echo "Cleaning up intermediate files..."
rm -f all_tags.txt packages_before.txt packages_after.txt packages_diff.txt project_commits.txt package_changelogs.txt
echo "Cleanup complete."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment