Skip to content

Commit cb9bbb4

Browse files
committed
build: update GitHub Actions workflow and scripts
- Refactor main.yml to improve CHANGELOG validation. - Add extract_changelog.sh for version extraction and notes. - Enhance error handling for missing CHANGELOG.md and invalid formats. - Ensure semantic versioning compliance in release process.
1 parent e202e59 commit cb9bbb4

2 files changed

Lines changed: 199 additions & 162 deletions

File tree

.github/workflows/main.yml

Lines changed: 101 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,167 +1,106 @@
11
name: Create Release from CHANGELOG
22

33
on:
4-
push:
5-
branches: [main]
6-
paths:
7-
- "CHANGELOG.md" # Only trigger when CHANGELOG.md is updated
4+
push:
5+
branches: [main]
6+
paths:
7+
- "CHANGELOG.md" # Only trigger when CHANGELOG.md is updated
88

9-
jobs:
10-
release:
11-
runs-on: ubuntu-latest
12-
permissions:
13-
contents: write
14-
15-
steps:
16-
- name: Checkout code
17-
uses: actions/checkout@v4
18-
with:
19-
fetch-depth: 0 # Fetch all history to get commit messages
20-
21-
- name: Extract latest version and notes
22-
id: changelog
23-
run: |
24-
# Extract the first version number from CHANGELOG.md
25-
VERSION=$(grep -m 1 '^## v' CHANGELOG.md | sed 's/^## \(v[0-9.]*[0-9]\(-[a-zA-Z0-9]*\)*\).*/\1/')
26-
echo "version=$VERSION" >> $GITHUB_OUTPUT
27-
28-
# Extract notes for this version (everything between this version header and the next version header)
29-
NOTES=$(awk -v ver="$VERSION" '
30-
BEGIN { found=0; capture=0; notes=""; }
31-
$0 ~ "^## " ver { found=1; capture=1; next; }
32-
$0 ~ /^## v/ && capture==1 { capture=0; }
33-
capture==1 { notes = notes $0 "\n"; }
34-
END { print notes; }
35-
' CHANGELOG.md)
36-
37-
# Get recent commits since last tag with GitHub usernames
38-
PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null || echo "")
39-
40-
# Function to get GitHub username from email
41-
get_github_username() {
42-
local email="$1"
43-
local commit_hash="$2"
44-
45-
# First try to extract username from GitHub noreply email
46-
if [[ "$email" =~ ([0-9]+\+)?([^@]+)@users\.noreply\.github\.com ]]; then
47-
echo "${BASH_REMATCH[2]}"
48-
return
49-
fi
50-
51-
# Try to get GitHub username via API using commit hash
52-
local github_user=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
53-
"https://api.github.com/repos/$GITHUB_REPOSITORY/commits/$commit_hash" | \
54-
jq -r '.author.login // empty' 2>/dev/null)
55-
56-
if [ -n "$github_user" ] && [ "$github_user" != "null" ]; then
57-
echo "$github_user"
58-
return
59-
fi
60-
61-
# Fallback: try to get user by email via API
62-
local api_user=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
63-
"https://api.github.com/search/users?q=$email+in:email" | \
64-
jq -r '.items[0].login // empty' 2>/dev/null)
65-
66-
if [ -n "$api_user" ] && [ "$api_user" != "null" ]; then
67-
echo "$api_user"
68-
else
69-
# Final fallback: use the part before @ in email
70-
echo "${email%%@*}"
71-
fi
72-
}
73-
74-
# Get ALL conventional commits, excluding merge commits to avoid duplicates
75-
if [ -z "$PREVIOUS_TAG" ]; then
76-
COMMIT_DATA=$(git log --pretty=format:"%H|%ae|%s" --no-merges --grep="^\(feat\|fix\|docs\|style\|refactor\|perf\|test\|build\|ci\|chore\|revert\)")
77-
else
78-
COMMIT_DATA=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"%H|%ae|%s" --no-merges --grep="^\(feat\|fix\|docs\|style\|refactor\|perf\|test\|build\|ci\|chore\|revert\)")
79-
fi
9+
concurrency:
10+
group: release-${{ github.ref }}
11+
cancel-in-progress: false # Don't cancel running releases
8012

81-
# Initialize categorized commit arrays
82-
FEATURES=""
83-
BUGFIXES=""
84-
OTHER_COMMITS=""
85-
86-
# Process ALL matching commits and categorize them
87-
while IFS='|' read -r hash email subject; do
88-
[ -z "$hash" ] && continue
89-
90-
# Get full commit message for PR detection
91-
message=$(git show -s --format=%B $hash)
92-
93-
# Extract username
94-
username=$(get_github_username "$email" "$hash")
95-
96-
# Check for ANY PR reference (#number)
97-
if [[ "$message" =~ \#([0-9]+) ]]; then
98-
pr_num=" (#${BASH_REMATCH[1]})"
99-
else
100-
pr_num=""
101-
fi
102-
103-
# Categorize based on conventional commit type
104-
if [[ "$subject" =~ ^feat(\(.+\))?:* ]]; then
105-
FEATURES="${FEATURES} - ${subject}${pr_num} (@$username)\n"
106-
elif [[ "$subject" =~ ^fix(\(.+\))?:* ]]; then
107-
BUGFIXES="${BUGFIXES} - ${subject}${pr_num} (@$username)\n"
108-
else
109-
OTHER_COMMITS="${OTHER_COMMITS} - ${subject}${pr_num} (@$username)\n"
110-
fi
111-
done <<< "$COMMIT_DATA"
112-
113-
# Build categorized commits section
114-
COMMITS=""
115-
if [ -n "$FEATURES" ]; then
116-
COMMITS="${COMMITS}#### 🚀 Features\n${FEATURES}\n"
117-
fi
118-
if [ -n "$BUGFIXES" ]; then
119-
COMMITS="${COMMITS}#### 🐛 Bug Fixes\n${BUGFIXES}\n"
120-
fi
121-
if [ -n "$OTHER_COMMITS" ]; then
122-
COMMITS="${COMMITS}#### 📝 Other Commits\n${OTHER_COMMITS}\n"
123-
fi
124-
125-
# Combine CHANGELOG notes with categorized commit messages
126-
if [ -n "$COMMITS" ]; then
127-
FULL_NOTES="${NOTES}\n\n### Commits\n${COMMITS}"
128-
else
129-
FULL_NOTES="${NOTES}"
130-
fi
131-
132-
# Save notes to output with correct GitHub multiline syntax
133-
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
134-
echo "notes<<$EOF" >> $GITHUB_OUTPUT
135-
echo -e "$FULL_NOTES" >> $GITHUB_OUTPUT
136-
echo "$EOF" >> $GITHUB_OUTPUT
137-
138-
# For debugging
139-
echo "Found version: $VERSION"
140-
echo "Release notes excerpt: $(echo "$NOTES" | head -3)..."
141-
echo "Commit messages excerpt: $(echo "$COMMITS" | head -3)..."
142-
143-
- name: Check for existing release
144-
id: check_release
145-
run: |
146-
VERSION=${{ steps.changelog.outputs.version }}
147-
if gh release view $VERSION &>/dev/null; then
148-
echo "Release already exists: $VERSION"
149-
echo "exists=true" >> $GITHUB_OUTPUT
150-
else
151-
echo "No existing release found for: $VERSION"
152-
echo "exists=false" >> $GITHUB_OUTPUT
153-
fi
154-
env:
155-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
156-
157-
- name: Create GitHub Release
158-
if: steps.check_release.outputs.exists == 'false'
159-
uses: softprops/action-gh-release@v1
160-
with:
161-
tag_name: ${{ steps.changelog.outputs.version }}
162-
name: "Release ${{ steps.changelog.outputs.version }}"
163-
body: ${{ steps.changelog.outputs.notes }}
164-
draft: false
165-
prerelease: ${{ contains(steps.changelog.outputs.version, '-') }}
166-
env:
167-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
13+
jobs:
14+
release:
15+
runs-on: ubuntu-latest
16+
permissions:
17+
contents: write
18+
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@v5
22+
23+
- name: Validate CHANGELOG format
24+
run: |
25+
if [[ ! -f "CHANGELOG.md" ]]; then
26+
echo "::error::CHANGELOG.md not found"
27+
exit 1
28+
fi
29+
30+
if ! grep -q "^## \[" CHANGELOG.md && ! grep -q "^## v" CHANGELOG.md; then
31+
echo "::error::CHANGELOG.md does not contain valid version headers"
32+
echo "::error::Expected format: '## [X.Y.Z]' or '## vX.Y.Z'"
33+
exit 1
34+
fi
35+
36+
- name: Extract latest version and notes
37+
id: changelog
38+
run: ./scripts/extract_changelog.sh
39+
40+
- name: Validate semantic version
41+
if: steps.changelog.outputs.is_unreleased != 'true'
42+
run: |
43+
VERSION=${{ steps.changelog.outputs.version }}
44+
# Remove 'v' prefix if present
45+
VERSION=${VERSION#v}
46+
47+
# Validate semantic versioning format
48+
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$ ]]; then
49+
echo "::error::Version '$VERSION' does not follow semantic versioning"
50+
echo "::error::Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]"
51+
exit 1
52+
fi
53+
54+
echo "::notice::Version $VERSION is valid semantic version"
55+
56+
- name: Check for existing release
57+
if: steps.changelog.outputs.is_unreleased != 'true'
58+
id: check_release
59+
run: |
60+
VERSION=${{ steps.changelog.outputs.version }}
61+
TAG="v$VERSION"
62+
if gh release view "$TAG" &>/dev/null; then
63+
echo "::notice::Release already exists: $TAG"
64+
echo "exists=true" >> $GITHUB_OUTPUT
65+
else
66+
echo "::notice::No existing release found for: $TAG"
67+
echo "exists=false" >> $GITHUB_OUTPUT
68+
fi
69+
env:
70+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
71+
72+
- name: Create GitHub Release
73+
if: steps.changelog.outputs.is_unreleased != 'true' && steps.check_release.outputs.exists == 'false'
74+
uses: softprops/action-gh-release@v2.5.0
75+
with:
76+
tag_name: v${{ steps.changelog.outputs.version }}
77+
name: "Release v${{ steps.changelog.outputs.version }}"
78+
body: ${{ steps.changelog.outputs.notes }}
79+
draft: false
80+
prerelease: ${{ contains(steps.changelog.outputs.version, '-') }}
81+
env:
82+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
83+
84+
- name: Verify Release Created
85+
if: steps.changelog.outputs.is_unreleased != 'true' && steps.check_release.outputs.exists == 'false'
86+
run: |
87+
TAG="v${{ steps.changelog.outputs.version }}"
88+
# Wait a moment for release to be fully created
89+
sleep 5
90+
91+
# Verify release exists
92+
if gh release view "$TAG" &>/dev/null; then
93+
RELEASE_URL=$(gh release view "$TAG" --json url --jq '.url')
94+
echo "::notice::Release successfully created: $RELEASE_URL"
95+
else
96+
echo "::error::Release verification failed for $TAG"
97+
exit 1
98+
fi
99+
env:
100+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
101+
102+
- name: Notify on Failure
103+
if: failure()
104+
run: |
105+
echo "::error::Release workflow failed"
106+
echo "::error::Please check CHANGELOG.md format and workflow logs"

scripts/extract_changelog.sh

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env bash
2+
# Extract version and release notes from CHANGELOG.md for GitHub Actions
3+
# Outputs to GitHub Actions environment using GITHUB_OUTPUT
4+
# Follows keepachangelog format
5+
6+
set -euo pipefail
7+
8+
# Constants
9+
readonly CHANGELOG_FILE="${1:-CHANGELOG.md}"
10+
readonly GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}"
11+
12+
# Main extraction function
13+
main() {
14+
local first_header version notes eof
15+
16+
# Validate CHANGELOG file exists
17+
if [[ ! -f "$CHANGELOG_FILE" ]]; then
18+
echo "::error::CHANGELOG.md not found at $CHANGELOG_FILE"
19+
exit 1
20+
fi
21+
22+
# Extract the first version header from CHANGELOG.md
23+
# Supports both formats: ## [version] and ## vX.Y.Z
24+
first_header=$(grep -m 1 '^## \[' "$CHANGELOG_FILE" || grep -m 1 '^## v' "$CHANGELOG_FILE" || true)
25+
26+
if [[ -z "$first_header" ]]; then
27+
echo "::error::No version header found in CHANGELOG.md"
28+
echo "::error::Expected format: '## [X.Y.Z]' or '## vX.Y.Z'"
29+
exit 1
30+
fi
31+
32+
# Extract version from header
33+
if [[ "$first_header" =~ ^\#\#\ \[([0-9]+\.[0-9]+\.[0-9]+[^]]*)\] ]]; then
34+
# Format: ## [version]
35+
version="${BASH_REMATCH[1]}"
36+
elif [[ "$first_header" =~ ^\#\#\ (v[0-9]+\.[0-9]+\.[0-9]+.*) ]]; then
37+
# Format: ## vX.Y.Z
38+
version="${BASH_REMATCH[1]}"
39+
elif [[ "$first_header" =~ ^\#\#\ \[([Uu]nreleased)\] ]]; then
40+
# Format: ## [Unreleased]
41+
version="${BASH_REMATCH[1]}"
42+
else
43+
echo "::error::Invalid version format in: $first_header"
44+
echo "::error::Expected: '## [X.Y.Z]' or '## vX.Y.Z'"
45+
exit 1
46+
fi
47+
48+
echo "version=$version" >> "$GITHUB_OUTPUT"
49+
echo "::notice::Detected version: $version"
50+
51+
# Check if version is "Unreleased"
52+
if [[ "$version" =~ ^[Uu]nreleased$ ]]; then
53+
echo "is_unreleased=true" >> "$GITHUB_OUTPUT"
54+
echo "::notice::Version is Unreleased, skipping release creation"
55+
exit 0
56+
fi
57+
58+
echo "is_unreleased=false" >> "$GITHUB_OUTPUT"
59+
60+
# Extract notes for this version (everything between this version header and the next ## header)
61+
notes=$(awk -v ver="$version" '
62+
BEGIN { found=0; capture=0; notes=""; }
63+
/^## \[/ {
64+
if (!found && index($0, ver) > 0) {
65+
found=1;
66+
capture=1;
67+
next;
68+
} else if (capture==1) {
69+
capture=0;
70+
}
71+
}
72+
/^## v/ {
73+
if (!found && index($0, ver) > 0) {
74+
found=1;
75+
capture=1;
76+
next;
77+
} else if (capture==1) {
78+
capture=0;
79+
}
80+
}
81+
capture==1 { notes = notes $0 "\n"; }
82+
END { print notes; }
83+
' "$CHANGELOG_FILE")
84+
85+
# Save notes to output with correct GitHub multiline syntax
86+
eof=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
87+
{
88+
echo "notes<<$eof"
89+
echo "$notes"
90+
echo "$eof"
91+
} >> "$GITHUB_OUTPUT"
92+
93+
# For debugging
94+
echo "Release notes excerpt:"
95+
echo "$notes" | head -10
96+
}
97+
98+
main "$@"

0 commit comments

Comments
 (0)