Skip to content
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
127 changes: 127 additions & 0 deletions .github/scripts/check-unreleased-changelog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
cd "$ROOT_DIR"

BASE_REF="${GITHUB_BASE_REF:-}"
REQUIRE_CHANGELOG_ALWAYS="${REQUIRE_CHANGELOG_ALWAYS:-false}"
ENFORCE_UNRELEASED_BULLET="${ENFORCE_UNRELEASED_BULLET:-false}"

if [[ -n "$BASE_REF" ]]; then
git fetch --no-tags --depth=1 origin "$BASE_REF" >/dev/null 2>&1 || true
DIFF_RANGE="origin/${BASE_REF}...HEAD"
mapfile -t CHANGED_FILES < <(git diff --name-only "$DIFF_RANGE")
else
if [[ -n "$(git status --porcelain)" ]]; then
mapfile -t CHANGED_FILES < <(
{
git diff --name-only HEAD
git ls-files --others --exclude-standard
} | sort -u
)
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
DIFF_RANGE="HEAD~1...HEAD"
mapfile -t CHANGED_FILES < <(git diff --name-only "$DIFF_RANGE")
else
echo "No comparable git range available; skipping changelog check."
exit 0
fi
fi

if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
echo "No changed files detected; skipping changelog check."
exit 0
fi

if ! printf '%s\n' "${CHANGED_FILES[@]}" | grep -qx 'CHANGELOG.md'; then
if [[ "$REQUIRE_CHANGELOG_ALWAYS" == "true" ]]; then
echo "ERROR: CHANGELOG.md must be updated in every commit."
exit 1
fi

TRIGGERS=(
'^src/'
'^tests/'
'^Makefile$'
)

EXEMPTS=(
'^\.github/'
'^LICENSE$'
'^README\.md$'
'^RELEASE_NOTES\.md$'
'^CHANGELOG\.md$'
'^\.gitignore$'
'^global\.json$'
)

needs_changelog=false
for file in "${CHANGED_FILES[@]}"; do
is_trigger=false
for pat in "${TRIGGERS[@]}"; do
if [[ "$file" =~ $pat ]]; then
is_trigger=true
break
fi
done

if [[ "$is_trigger" == false ]]; then
continue
fi

is_exempt=false
for pat in "${EXEMPTS[@]}"; do
if [[ "$file" =~ $pat ]]; then
is_exempt=true
break
fi
done

if [[ "$is_exempt" == false ]]; then
needs_changelog=true
break
fi
done

if [[ "$needs_changelog" == true ]]; then
echo "ERROR: Functional changes detected but CHANGELOG.md was not updated."
echo "Add a short one-line bullet under ## [Unreleased]."
exit 1
fi

echo "No changelog-required files changed."
exit 0
fi

if ! grep -q '^## \[Unreleased\]' CHANGELOG.md; then
echo "ERROR: CHANGELOG.md must contain a top-level '## [Unreleased]' section."
exit 1
fi

UNRELEASED_BLOCK=$(
awk '
/^## \[Unreleased\]/{in_block=1; next}
/^## \[/{if(in_block){exit}}
in_block{print}
' CHANGELOG.md
)

if [[ -z "${UNRELEASED_BLOCK//$'\n'/}" ]]; then
HEAD_SUBJECT="$(git log -1 --pretty=%s)"
if [[ "$HEAD_SUBJECT" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Changelog gate passed (release commit allows empty ## [Unreleased])."
exit 0
fi
echo "ERROR: ## [Unreleased] section is empty."
exit 1
fi

if [[ "$ENFORCE_UNRELEASED_BULLET" == "true" ]]; then
if ! printf '%s\n' "$UNRELEASED_BLOCK" | grep -qE '^- '; then
echo "ERROR: ## [Unreleased] must contain at least one bullet line starting with '- '."
exit 1
fi
fi

echo "Changelog gate passed."
42 changes: 42 additions & 0 deletions .github/scripts/extract-release-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version>"
exit 2
fi

version="$1"
section_header="## [${version}]"

if [[ ! -f CHANGELOG.md ]]; then
echo "ERROR: CHANGELOG.md not found."
exit 1
fi

section_body="$(
awk -v header="$section_header" '
BEGIN { in_section = 0 }
$0 == header { in_section = 1; next }
/^## \[/ && in_section { exit }
in_section { print }
' CHANGELOG.md
)"

if [[ -z "${section_body//[[:space:]]/}" ]]; then
echo "ERROR: Missing or empty changelog section '${section_header}'."
echo "Add a '${section_header}' section to CHANGELOG.md before tagging."
exit 1
fi

if ! grep -q '^[[:space:]]*-\s\+' <<<"$section_body"; then
echo "ERROR: Section '${section_header}' must include at least one bullet line."
exit 1
fi

if ! grep -q '\*\*Full Changelog\*\*:' <<<"$section_body"; then
echo "ERROR: Section '${section_header}' must include a '**Full Changelog**' compare link."
exit 1
fi

printf '%s\n' "$section_body"
169 changes: 169 additions & 0 deletions .github/scripts/release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "Usage: $0 <version> [dryrun]"
echo " version: X.Y.Z or X.Y.Z-suffix"
echo " dryrun : true|false (default: false)"
exit 2
fi

version="$1"
dryrun="${2:-false}"

if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "ERROR: Invalid version '$version'. Expected semantic version format."
exit 1
fi

repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"

if [[ ! -f CHANGELOG.md ]]; then
echo "ERROR: CHANGELOG.md not found."
exit 1
fi

if [[ "$dryrun" != "true" && -n "$(git status --porcelain)" ]]; then
echo "ERROR: Working tree is not clean. Commit or stash changes before running release."
exit 1
fi

if git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then
echo "ERROR: Tag '${version}' already exists."
exit 1
fi

if grep -q "^## \[${version}\]$" CHANGELOG.md; then
echo "ERROR: CHANGELOG section '## [${version}]' already exists."
exit 1
fi

unreleased_body="$(
awk '
BEGIN { in_section = 0 }
$0 == "## [Unreleased]" { in_section = 1; next }
/^## \[/ && in_section { exit }
in_section { print }
' CHANGELOG.md
)"

if [[ -z "${unreleased_body//[[:space:]]/}" ]]; then
echo "ERROR: Unreleased section is empty."
exit 1
fi

if ! grep -q '^[[:space:]]*-\s\+' <<<"$unreleased_body"; then
echo "ERROR: Unreleased section must include at least one bullet."
exit 1
fi

candidate_tags="$(git tag --list | grep -E '^(r)?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$' | sort -V || true)"

previous_tag="$(
{
printf '%s\n' "$candidate_tags"
printf '%s\n' "$version"
} \
| sed '/^$/d' \
| sort -V \
| awk -v target="$version" '
$0 == target { print prev; exit }
{ prev = $0 }
'
)"

if [[ -z "$previous_tag" ]]; then
echo "ERROR: Could not determine previous tag for '$version'."
echo "Create the compare link manually in CHANGELOG.md and tag manually."
exit 1
fi

remote_url="$(git remote get-url origin 2>/dev/null || true)"
if [[ -z "$remote_url" ]]; then
echo "ERROR: Could not determine origin remote URL."
exit 1
fi

repo_slug=""
if [[ "$remote_url" =~ github.com[:/]([^/]+/[^/]+)(\.git)?$ ]]; then
repo_slug="${BASH_REMATCH[1]}"
fi
repo_slug="${repo_slug%.git}"

if [[ -z "$repo_slug" ]]; then
echo "ERROR: Could not parse GitHub owner/repo from origin remote '$remote_url'."
exit 1
fi

compare_link="**Full Changelog**: https://github.com/${repo_slug}/compare/${previous_tag}...${version}"

tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT

stripped_changelog="${tmp_dir}/changelog-stripped.md"
new_section_file="${tmp_dir}/new-section.md"
updated_changelog="${tmp_dir}/CHANGELOG.md"

awk '
BEGIN { skip = 0 }
$0 == "## [Unreleased]" {
print
print ""
skip = 1
next
}
skip && /^## \[/ { skip = 0 }
!skip { print }
' CHANGELOG.md > "$stripped_changelog"

{
echo "## [${version}]"
echo ""
printf '%s\n' "$unreleased_body"
echo ""
echo "$compare_link"
} > "$new_section_file"

awk -v section_file="$new_section_file" '
BEGIN { inserted = 0; skip_next_blank = 0 }
$0 == "## [Unreleased]" && inserted == 0 {
print
print ""
while ((getline line < section_file) > 0) {
print line
}
close(section_file)
print ""
inserted = 1
skip_next_blank = 1
next
}
skip_next_blank == 1 && $0 == "" {
skip_next_blank = 0
next
}
{ print }
' "$stripped_changelog" > "$updated_changelog"

if ! grep -q "^## \[${version}\]$" "$updated_changelog"; then
echo "ERROR: Failed to materialize CHANGELOG section for ${version}."
exit 1
fi

if [[ "$dryrun" == "true" ]]; then
echo "[DRY RUN] Would update CHANGELOG.md, commit and create annotated tag '${version}'."
echo "[DRY RUN] Previous tag: ${previous_tag}"
echo "[DRY RUN] Compare link: ${compare_link}"
exit 0
fi

cp "$updated_changelog" CHANGELOG.md

git add CHANGELOG.md
git commit -m "chore(release): ${version}"
git tag -a "${version}" -m "Release ${version}"

echo "Release prepared successfully."
echo "Next steps:"
echo " git push origin main --follow-tags"
Loading
Loading