diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85232db81d..a51c73d9e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: inputs: branch: - description: 'Target branch for release' + description: 'Target branch to release from' required: true default: 'master' type: string @@ -11,6 +11,11 @@ on: description: 'Release version' required: true type: string + + previous_version: + description: 'Previous version, starting point for release notes generator' + required: true + type: string jobs: release: runs-on: ubuntu-latest @@ -40,10 +45,21 @@ jobs: with: working-directory: sssd + - name: Install release notes dependencies + shell: bash + run: dnf install -y pandoc python3-pypandoc + - name: Execute release script working-directory: sssd shell: bash env: GH_TOKEN: ${{ secrets.BOT_TOKEN }} run: | - ./scripts/release.sh "${{ inputs.branch }}" "${{ inputs.version }}" + ./scripts/release.sh "${{ inputs.branch }}" "${{ inputs.version }}" "${{ inputs.previous_version }}" + + - name: Execute release notes script + working-directory: sssd + shell: bash + run: | + # Release notes file is generated from the release script + ./scripts/generate-full-release-notes.sh "${{ inputs.version }}" "/tmp/sssd-${{ inputs.version }}.rst" sssd-bot "${{ secrets.BOT_TOKEN }}" diff --git a/scripts/fixed-issues.sh b/scripts/fixed-issues.sh new file mode 100755 index 0000000000..b5cef3de14 --- /dev/null +++ b/scripts/fixed-issues.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Parse arguments +FROM="" +TO="HEAD" +FORMAT="plain" + +# Pattern to find issues +pattern="Resolves: https://github.com/SSSD/sssd/issues/[0-9]+" + +while [[ $# -gt 0 ]]; do + case $1 in + --from=*) + FROM="${1#*=}" + shift + ;; + --from) + FROM="$2" + shift 2 + ;; + --to=*) + TO="${1#*=}" + shift + ;; + --to) + TO="$2" + shift 2 + ;; + --format=*) + FORMAT="${1#*=}" + shift + ;; + --format) + FORMAT="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: $0 --from [--to ] [--format plain|rst|md]" >&2 + exit 1 + ;; + esac +done + +# Validate required arguments +if [[ -z "$FROM" ]]; then + echo "Error: --from is required" >&2 + echo "Usage: $0 --from [--to ] [--format plain|rst|md]" >&2 + exit 1 +fi + +# Validate format +if [[ "$FORMAT" != "plain" && "$FORMAT" != "rst" && "$FORMAT" != "md" ]]; then + echo "Error: --format must be 'plain', 'rst' or 'md'" >&2 + exit 1 +fi + +# Extract issue URLs from git log +issue_urls=$( + git log "$FROM..$TO" \ + | grep -oE "$pattern" \ + | sed 's/^Resolves: //' \ + | sort -u \ + | grep -v '^$' \ + || true +) + +if [[ -z "$issue_urls" ]]; then + echo "No issues found in commits from $FROM to $TO" >&2 + exit 0 +fi + +# Process each issue +for url in $issue_urls; do + # Extract issue number from URL + issue_number=$(echo "$url" | grep -oE '[0-9]+$') + + # Get issue details using gh + issue_json=$( + gh issue view "$issue_number" --json number,title,state 2>/dev/null || echo "" + ) + + if [[ -z "$issue_json" ]]; then + echo "Warning: Could not fetch issue #$issue_number" >&2 + continue + fi + + # Parse JSON with jq + state=$(echo "$issue_json" | jq -r '.state') + title=$(echo "$issue_json" | jq -r '.title') + + # Only include closed issues + if [[ "$state" != "CLOSED" ]]; then + continue + fi + + # Output based on format + case "$FORMAT" in + plain) + echo "* #$issue_number $url - $title" + ;; + md) + echo "* [#$issue_number]($url) - $title" + ;; + rst) + echo "* \`#$issue_number <$url>\`__ - $title" + ;; + esac +done diff --git a/scripts/generate-full-release-notes.sh b/scripts/generate-full-release-notes.sh new file mode 100755 index 0000000000..80281125d2 --- /dev/null +++ b/scripts/generate-full-release-notes.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Generate release notes for sssd.io + +set -euo pipefail + +FROM="" +TO="HEAD" +VERSION="" +FORMAT="rst" +scriptdir=`realpath \`dirname "$0"\`` + +while [[ $# -gt 0 ]]; do + case $1 in + --from=*) + FROM="${1#*=}" + shift + ;; + --from) + FROM="$2" + shift 2 + ;; + --to=*) + TO="${1#*=}" + shift + ;; + --to) + TO="$2" + shift 2 + ;; + --version=*) + VERSION="${1#*=}" + shift + ;; + --version) + VERSION="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: $0 --from --to --version " >&2 + exit 1 + ;; + esac +done + +notes=`$scriptdir/generate-release-notes.py --from $FROM --to $TO --version $VERSION --format $FORMAT` +fixed_issues=`$scriptdir/fixed-issues.sh --from $FROM --to $TO --format $FORMAT` +gitlog=`git shortlog --pretty=format:"%h %s" -w0,4 $FROM..$TO` + +echo "$notes" +echo "" +echo "Tickets Fixed" +echo "-------------" +echo "" +echo "$fixed_issues" +echo "" +echo "Detailed Changelog" +echo "------------------" +echo "" +echo ".. code-block:: release-notes-shortlog" +echo "" +echo " $ git shortlog --pretty=format:\"%h %s\" -w0,4 $FROM..$TO" +echo "" +echo "$gitlog" | sed 's/^/ /' +echo "" diff --git a/scripts/generate-release-notes.py b/scripts/generate-release-notes.py new file mode 100755 index 0000000000..75d162de17 --- /dev/null +++ b/scripts/generate-release-notes.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + +import argparse +import re +import subprocess +import sys +import pypandoc + + +class ReleaseNote: + """Represents a category of release notes.""" + + def __init__(self, tag, title): + self.tag = tag + self.title = title + + def findall(self, git_log): + """Extract all notes for this tag from the git log.""" + # Pattern matches :tag: followed by content until empty line or next tag + pattern = rf"^ *:{self.tag}:((?:(?!(?:^ *:\w+:| *$)).*\n)+)" + matches = re.findall(pattern, git_log, re.MULTILINE) + + if not matches: + return [] + + notes = [] + for match in matches: + # Join multiline notes, preserving markdown formatting + note = " ".join([line.strip() for line in match.split("\n")]) + notes.append(f"* {note}") + + return notes + + def generate(self, git_log): + notes = self.findall(git_log) + if not notes: + return "" + + output = f"### {self.title}\n\n" + output += "\n".join(notes) + return output + + +class ReleaseNotesGenerator: + """Generate release notes from git commit messages.""" + + def __init__(self, from_ref, to_ref, version): + self.from_ref = from_ref + self.to_ref = to_ref + self.version = version + + self.project_name = "SSSD" + self.categories = [ + ReleaseNote("relnote", "General information"), + ReleaseNote("feature", "New features"), + ReleaseNote("fixes", "Important fixes"), + ReleaseNote("packaging", "Packaging changes"), + ReleaseNote("config", "Configuration changes"), + ] + + def get_git_log(self, from_ref, to_ref): + """Get git log between two references.""" + result = subprocess.run( + ["git", "log", f"{from_ref}..{to_ref}"], + capture_output=True, + text=True, + check=True, + ) + + return result.stdout + + def generate(self): + """Generate release notes in markdown.""" + git_log = self.get_git_log(self.from_ref, self.to_ref) + output = f"# {self.project_name} {self.version} Release Notes\n" + output += "\n" + output += "## Highlights\n" + + # Generate sections for each category + for category in self.categories: + notes = category.generate(git_log) + if notes: + output += "\n" + output += notes + output += "\n" + + return output.strip() + + +def main(): + parser = argparse.ArgumentParser( + description="Generate release notes from git commit messages" + ) + parser.add_argument( + "--from", type=str, required=True, dest="from_ref", help="Start point reference" + ) + parser.add_argument( + "--to", + type=str, + default="HEAD", + dest="to_ref", + help="End point reference (default: HEAD)", + ) + parser.add_argument( + "--version", type=str, required=True, help="New release version" + ) + parser.add_argument( + "--format", + type=str, + choices=["md", "rst"], + default="md", + help="Output format (default: md)", + ) + + args = parser.parse_args() + + try: + generator = ReleaseNotesGenerator(args.from_ref, args.to_ref, args.version) + output = generator.generate() + + # Convert markdown to requested format with 80 char line wrapping + extra_args = ["--wrap=auto", "--columns=80"] + output = pypandoc.convert_text( + output, args.format, format="md", extra_args=extra_args + ) + + print(output) + except subprocess.CalledProcessError as e: + print(f"Error: git command failed: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000000..93f497bd40 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# Open pull request with release notes against sssd.io + +set -e -o pipefail + +# Usage +if [ "$#" -ne 4 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +# Create working directory +scriptdir=`realpath \`dirname "$0"\`` +wd=`mktemp -d` +trap 'rm -rf "$wd"' EXIT + +# Initial setup +VERSION=$1 +PATH_TO_RN=$2 +FORK_USER=$3 +FORK_TOKEN=$4 + +GITHUB_REPOSITORY="SSSD/sssd.io" +OWNER=`echo "$GITHUB_REPOSITORY" | cut -d / -f 1` +REPOSITORY=`echo "$GITHUB_REPOSITORY" | cut -d / -f 2` +TARGET="master" +RN_BRANCH_NAME="$OWNER-$REPOSITORY-relnotes-$VERSION" + +echo "GitHub Repository: $OWNER/$REPOSITORY" +echo "Target Branch: $TARGET" +echo "Release Notes Branch: $RN_BRANCH_NAME" +echo "" +echo "Action Directory: $scriptdir" +echo "Working Directory: $wd" +echo "" + +pushd "$wd" +set -x + +# Login with token to GitHub CLI, GH_TOKEN variable is used in GitHub Actions +set +x +if [ -z "$GH_TOKEN" ]; then + echo $FORK_TOKEN > .token + gh auth login --with-token < .token + rm -f .token +fi +set -x + +# Clone repository and fetch the pull request +git clone "https://github.com/$OWNER/$REPOSITORY.git" . +git remote add "$FORK_USER" "https://$FORK_USER:$FORK_TOKEN@github.com/$FORK_USER/$REPOSITORY.git" +git checkout "$TARGET" +gh repo set-default "$GITHUB_REPOSITORY" + +# Create new branch that we will work on +git checkout -b "$RN_BRANCH_NAME" "$TARGET" + +# Copy release notes and update releases.rst +# Insert new release before the first occurrence of ".. release::" +cp -f "$PATH_TO_RN" "./src/release-notes/sssd-$VERSION.rst" +TODAY=$(date +%Y-%m-%d) +RELEASES_FILE="./src/releases.rst" +NEW_RELEASE=$(cat < "$BODY_FILE" < [ ]" >&2 +if [ "$#" -ne 3 ] && [ "$#" -ne 5 ]; then + echo "Usage: $0 [ ]" >&2 exit 1 fi @@ -22,8 +22,9 @@ scriptdir=`realpath \`dirname "$0"\`` rootdir=`realpath "$scriptdir/.."` branch=$1 version=$2 -github_repo="${3:-SSSD/sssd}" -git_remote="${4:-origin}" +prev_version=$3 +github_repo="${4:-SSSD/sssd}" +git_remote="${5:-origin}" echo "SSSD sources location: $rootdir" echo "Repository: $github_repo" @@ -31,6 +32,7 @@ echo "Remote: $git_remote" echo "Temporary directory: $tmpdir" echo "Target branch: $branch" echo "Released version: $version" +echo "Previous version: $prev_version" # Work in a temporary copy of the repository pushd $tmpdir @@ -108,6 +110,7 @@ GROUP_START "Create GitHub release" gh release create "$version" \ --repo "$github_repo" \ --title "sssd-$version" \ + --notes "[**See full release notes here.**](https://sssd.io/release-notes/sssd-$version.html)" \ --generate-notes \ --verify-tag \ --draft \ @@ -115,3 +118,8 @@ gh release create "$version" \ "sssd-${version}.tar.gz.asc" \ "sssd-${version}.tar.gz.sha256sum" GROUP_END + +GROUP_START "Generate release notes" +./scripts/full-release-notes.sh --from "$prev_version" --to HEAD --version "$version" > "/tmp/sssd-$version.rst" +echo "Release notes stored at /tmp/sssd-$version.rst" +GROUP_END