diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d33e96..5eab736 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: permissions: contents: write + pull-requests: read jobs: release: @@ -20,6 +21,8 @@ jobs: - name: Generate changelog id: changelog + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail TAG="${GITHUB_REF_NAME}" @@ -38,27 +41,103 @@ jobs: FEATS="" FIXES="" + CONTRIBUTORS="" + declare -A SEEN_PRS=() + declare -A SEEN_CONTRIBUTORS=() + + add_contributor() { + local login="$1" + local avatar="$2" + local profile="$3" + [ -z "$login" ] && return + [ -n "${SEEN_CONTRIBUTORS[$login]+x}" ] && return + SEEN_CONTRIBUTORS["$login"]=1 + avatar="${avatar:-https://github.com/${login}.png?size=96}" + profile="${profile:-https://github.com/${login}}" + CONTRIBUTORS="${CONTRIBUTORS}\"@${login}\" " + } + + parse_conventional() { + local text="$1" + ENTRY_TYPE="" + ENTRY_TITLE="" + local pattern='^(feat|fix)(\([^)]*\))?!?:[[:space:]](.+)$' + if [[ "$text" =~ $pattern ]]; then + ENTRY_TYPE="${BASH_REMATCH[1]}" + ENTRY_TITLE="${BASH_REMATCH[3]}" + return 0 + fi + return 1 + } + + author_suffix() { + local login="$1" + local profile="$2" + local name="$3" + if [ -n "$login" ]; then + printf ' by [@%s](%s)' "$login" "${profile:-https://github.com/${login}}" + elif [ -n "$name" ]; then + printf ' by %s' "$name" + fi + } # %H = hash, %s = subject (first line) while IFS= read -r line; do [ -z "$line" ] && continue - sha="${line%% *}" - subject="${line#* }" - # match conventional commits: feat, fix (with optional scope and breaking !) - if echo "$subject" | grep -qE '^(feat|fix)(\([^)]+\))?!?: '; then - type="$(echo "$subject" | sed -E 's/^([a-z]+)(\([^)]+\))?!?:.*/\1/')" - # strip type prefix for display, keep rest as title - title="$(echo "$subject" | sed -E 's/^[a-z]+(\([^)]+\))?!?: //')" - short="$(echo "$sha" | cut -c1-7)" + sha="${line%%$'\t'*}" + subject="${line#*$'\t'}" + + commit_login="$(gh api "repos/${REPO}/commits/${sha}" --jq '.author.login // empty')" + commit_name="$(gh api "repos/${REPO}/commits/${sha}" --jq '.commit.author.name // empty')" + commit_avatar="" + commit_profile="" + if [ -n "$commit_login" ]; then + commit_avatar="$(gh api "repos/${REPO}/commits/${sha}" --jq '.author.avatar_url // empty')" + commit_profile="$(gh api "repos/${REPO}/commits/${sha}" --jq '.author.html_url // empty')" + add_contributor "$commit_login" "$commit_avatar" "$commit_profile" + fi + + pr="$(gh api "repos/${REPO}/commits/${sha}/pulls" --jq '.[0].number // empty')" + if [ -n "$pr" ]; then + pr_title="$(gh api "repos/${REPO}/pulls/${pr}" --jq '.title // empty')" + pr_login="$(gh api "repos/${REPO}/pulls/${pr}" --jq '.user.login // empty')" + pr_avatar="$(gh api "repos/${REPO}/pulls/${pr}" --jq '.user.avatar_url // empty')" + pr_profile="$(gh api "repos/${REPO}/pulls/${pr}" --jq '.user.html_url // empty')" + pr_url="$(gh api "repos/${REPO}/pulls/${pr}" --jq '.html_url // empty')" + add_contributor "$pr_login" "$pr_avatar" "$pr_profile" + + # A conflict-resolution commit and the original PR commit can + # both be reachable from a release. Render the PR only once and + # attribute it to the PR author, never to the merge author. + if [ -z "${SEEN_PRS[$pr]+x}" ]; then + candidate="$pr_title" + if ! parse_conventional "$candidate"; then + candidate="$subject" + parse_conventional "$candidate" || true + fi + if [ -n "$ENTRY_TYPE" ]; then + SEEN_PRS["$pr"]=1 + entry="- ${ENTRY_TITLE} ([#${pr}](${pr_url}))" + entry="${entry}$(author_suffix "$pr_login" "$pr_profile" "$commit_name")" + if [ "$ENTRY_TYPE" = "feat" ]; then + FEATS="${FEATS}${entry}"$'\n' + elif [ "$ENTRY_TYPE" = "fix" ]; then + FIXES="${FIXES}${entry}"$'\n' + fi + fi + fi + elif parse_conventional "$subject"; then + short="${sha:0:7}" url="https://github.com/${REPO}/commit/${sha}" - entry="- ${title} ([\`${short}\`](${url}))" - if [ "$type" = "feat" ]; then + entry="- ${ENTRY_TITLE} ([\`${short}\`](${url}))" + entry="${entry}$(author_suffix "$commit_login" "$commit_profile" "$commit_name")" + if [ "$ENTRY_TYPE" = "feat" ]; then FEATS="${FEATS}${entry}"$'\n' - elif [ "$type" = "fix" ]; then + elif [ "$ENTRY_TYPE" = "fix" ]; then FIXES="${FIXES}${entry}"$'\n' fi fi - done < <(git log "$RANGE" --format="%H %s" --no-merges) + done < <(git log "$RANGE" --format="%H%x09%s" --no-merges) { echo "# ${TAG}" @@ -79,6 +158,12 @@ jobs: echo "_No relevant conventional commits (feat/fix) in this release._" echo "" fi + if [ -n "$CONTRIBUTORS" ]; then + echo "## Contributors" + echo "" + printf "%s\n" "$CONTRIBUTORS" + echo "" + fi echo "---" echo "" if [ -n "$PREV_TAG" ]; then