This is an automated email from the ASF dual-hosted git repository.

mgrund pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark-connect-go.git


The following commit(s) were added to refs/heads/master by this push:
     new 3953d64  [FEAT] Create Script to Automate Release
3953d64 is described below

commit 3953d64636fe8fc9c1c7e08f881adfa35ca74665
Author: Martin Grund <martin.gr...@databricks.com>
AuthorDate: Fri Aug 15 15:39:26 2025 +0200

    [FEAT] Create Script to Automate Release
    
    ### What changes were proposed in this pull request?
    I've created a comprehensive Apache release script with the following 
features:
    
    Files Created:
    
    1. dev/release.py - Main release script with all requested functionality
    2. dev/requirements.txt - Python dependencies
    3. dev/README.md - Usage documentation
    
    #### Script Features:
    
    * ✅ Tag Management: Creates and pushes Git tags based on specified commit
    * ✅ GitHub Release: Creates draft releasesusing GitHub API
    * ✅ Release Notes: Auto-generates notes from commits between tags, with 
interactive editing
    * ✅ Pre-release Support: Optional pre-release flag
    * ✅ Artifact Download: Downloads GitHub's auto-generated source archives
    * ✅ GPG Signing: Creates detached signatures for all artifacts
    * ✅ Signature Verification: Verifies all signatures before upload
    * ✅ Error Handling: Comprehensive error handling and validation
    
    #### Key Dependencies:
    
      - requests - For HTTP requests to download artifacts
      - PyGithub - For GitHub API interactions
      - gitpython - For Git operations and commit history
    
    #### Usage Example:
    
    ```
      cd dev
      python -m venv venv && source venv/bin/activate
      pip install -r requirements.txt
    
      export GITHUB_TOKEN=your_token_here
    
      ./release.py \
        --tag v0.2.0 \
        --prev-tag v0.1.0 \
        --commit abc123def456 \
        --gpg-user your.emailexample.com \
        --prerelease  # optional
    
    ```
    
    ### Why are the changes needed?
    CI
    
    ### Does this PR introduce _any_ user-facing change?
    No
    
    
    
    Closes #160 from grundprinzip/improve_release.
    
    Authored-by: Martin Grund <martin.gr...@databricks.com>
    Signed-off-by: Martin Grund <martin.gr...@databricks.com>
---
 dev/README.md        | 118 +++++++++++++++++++
 dev/release.py       | 312 +++++++++++++++++++++++++++++++++++++++++++++++++++
 dev/requirements.txt |   3 +
 3 files changed, 433 insertions(+)

diff --git a/dev/README.md b/dev/README.md
new file mode 100644
index 0000000..c69adf5
--- /dev/null
+++ b/dev/README.md
@@ -0,0 +1,118 @@
+# Release Script for Apache Spark Connect Go
+
+This directory contains the release automation script for the Apache Spark 
Connect Go project.
+
+## Prerequisites
+
+1. **Python Environment**: Create a virtual environment and install 
dependencies:
+   ```bash
+   python -m venv venv
+   source venv/bin/activate  # On Windows: venv\Scripts\activate
+   pip install -r requirements.txt
+   ```
+
+2. **GitHub Token**: Create a GitHub personal access token with the following 
permissions:
+   - `repo` (Full control of private repositories)
+   - `write:packages` (Upload packages to GitHub Package Registry)
+
+3. **GPG Key**: Ensure you have a GPG key set up for signing:
+   ```bash
+   # List available keys
+   gpg --list-secret-keys
+   
+   # If you don't have a key, create one
+   gpg --gen-key
+   ```
+
+## Usage
+
+```bash
+./release.py --tag <new_tag> --prev-tag <previous_tag> --commit <commit_sha> 
--gpg-user <gpg_user_id> [options]
+```
+
+### Required Arguments
+
+- `--tag`: The new tag version (e.g., `v0.2.0`)
+- `--prev-tag`: The previous tag version for generating release notes (e.g., 
`v0.1.0`)
+- `--commit`: The commit SHA that the tag should point to
+- `--gpg-user`: Your GPG user ID for signing (email or key ID)
+
+### Optional Arguments
+
+- `--prerelease`: Mark the release as a pre-release
+- `--repo`: GitHub repository in format `owner/name` (default: 
`apache/spark-connect-go`)
+- `--token`: GitHub token (alternatively set `GITHUB_TOKEN` environment 
variable)
+
+### Environment Variables
+
+- `GITHUB_TOKEN`: GitHub personal access token
+
+## Example Usage
+
+```bash
+# Set GitHub token
+export GITHUB_TOKEN=ghp_your_token_here
+
+# Create a regular release
+./release.py \
+  --tag v0.2.0 \
+  --prev-tag v0.1.0 \
+  --commit abc123def456 \
+  --gpg-user your.em...@example.com
+
+# Create a pre-release
+./release.py \
+  --tag v0.2.0-rc1 \
+  --prev-tag v0.1.0 \
+  --commit abc123def456 \
+  --gpg-user your.em...@example.com \
+  --prerelease
+```
+
+## What the Script Does
+
+1. **Creates and pushes tag**: Creates a Git tag at the specified commit and 
pushes it to GitHub
+2. **Generates release notes**: Automatically creates initial release notes 
from commits between tags
+3. **Interactive input**: Prompts you to enter/modify the release description
+4. **Creates GitHub release**: Creates a draft release on GitHub
+5. **Downloads artifacts**: Downloads the automatically generated source 
archives (.tar.gz, .zip)
+6. **Signs artifacts**: Creates detached GPG signatures for each artifact
+7. **Verifies signatures**: Confirms that all signatures are valid
+8. **Uploads signatures**: Uploads the signature files to the GitHub release
+
+## Output
+
+The script creates:
+- A new Git tag pushed to GitHub
+- A draft GitHub release with:
+  - Source code archives (automatically generated by GitHub)
+  - Detached GPG signatures (.asc files)
+  - Release notes based on commits
+
+## Security Notes
+
+- All artifacts are signed with your GPG key
+- Signatures are verified before upload
+- The release is created as a draft first for review
+- Your GPG key must be available in your keyring
+
+## Troubleshooting
+
+### GPG Issues
+```bash
+# If GPG signing fails, check your key
+gpg --list-secret-keys
+
+# Test signing
+echo "test" | gpg --clearsign --local-user your.em...@example.com
+```
+
+### GitHub API Issues
+- Ensure your token has the correct permissions
+- Check rate limits if requests fail
+- Verify repository access
+
+### Git Issues
+- Ensure you're in the correct repository directory
+- Check that the commit SHA exists
+- Verify you have push permissions to the repository
\ No newline at end of file
diff --git a/dev/release.py b/dev/release.py
new file mode 100755
index 0000000..b3af95d
--- /dev/null
+++ b/dev/release.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+"""
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+import argparse
+import os
+import subprocess
+import sys
+import tempfile
+import requests
+from pathlib import Path
+from typing import List, Dict, Any
+
+import git
+from github import Github
+
+
+def run_command(cmd: List[str], cwd: str = None, check: bool = True) -> 
subprocess.CompletedProcess:
+    """Run a shell command and return the result."""
+    print(f"Running: {' '.join(cmd)}")
+    result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, 
check=False)
+
+    if result.returncode != 0 and check:
+        print(f"Command failed with return code {result.returncode}")
+        print(f"STDOUT: {result.stdout}")
+        print(f"STDERR: {result.stderr}")
+        sys.exit(1)
+
+    return result
+
+
+def get_commits_between_tags(repo_path: str, previous_tag: str, commit_sha: 
str) -> List[Dict[str, str]]:
+    """Get commits between previous tag and current commit."""
+    try:
+        repo = git.Repo(repo_path)
+
+        # Get commits from previous tag to current commit
+        commits = list(repo.iter_commits(f"{previous_tag}..{commit_sha}"))
+
+        commit_info = []
+        for commit in commits:
+            commit_info.append({
+                'sha': commit.hexsha[:8],  # Short commit ID
+                'author': commit.author.name,
+                'message': commit.message.split('\n')[0]  # Subject line only
+            })
+
+        return commit_info
+
+    except Exception as e:
+        print(f"Error getting commits: {e}")
+        return []
+
+
+def create_release_notes(commits: List[Dict[str, str]]) -> str:
+    """Create initial release notes from commits."""
+    if not commits:
+        return "## Changes\n\nNo commits found between releases.\n"
+
+    notes = "## Changes\n\n"
+    for commit in commits:
+        notes += f"* {commit['sha']} - {commit['message']} 
({commit['author']})\n"
+
+    return notes
+
+
+def verify_gpg_key(gpg_user: str) -> bool:
+    """Verify that the GPG key exists and can be used for signing."""
+    try:
+        result = run_command(['gpg', '--list-secret-keys', gpg_user], 
check=False)
+        return result.returncode == 0
+    except Exception:
+        return False
+
+
+def sign_file(file_path: str, gpg_user: str) -> str:
+    """Create a detached GPG signature for a file."""
+    signature_path = f"{file_path}.asc"
+
+    cmd = [
+        'gpg',
+        '--local-user', gpg_user,
+        '--armor',
+        '--detach-sign',
+        file_path
+    ]
+
+    run_command(cmd)
+
+    if not os.path.exists(signature_path):
+        raise RuntimeError(f"Signature file {signature_path} was not created")
+
+    return signature_path
+
+
+def verify_signature(file_path: str, signature_path: str) -> bool:
+    """Verify a GPG signature."""
+    try:
+        result = run_command(['gpg', '--verify', signature_path, file_path], 
check=False)
+        return result.returncode == 0
+    except Exception:
+        return False
+
+
+def download_file(url: str, local_path: str):
+    """Download a file from URL to local path."""
+    print(f"Downloading {url} to {local_path}")
+
+    response = requests.get(url, stream=True)
+    response.raise_for_status()
+
+    with open(local_path, 'wb') as f:
+        for chunk in response.iter_content(chunk_size=8192):
+            f.write(chunk)
+
+
+def upload_release_asset(release, file_path: str):
+    """Upload a file as a release asset."""
+    print(f"Uploading {file_path} to release")
+
+    filename = os.path.basename(file_path)
+
+    # Use the release object's upload_asset method
+    # PyGithub expects: upload_asset(path, label=None, content_type=None, 
name=None)
+    release.upload_asset(file_path, label=filename, name=filename)
+
+
+def main():
+    parser = argparse.ArgumentParser(description='Create and sign Apache Spark 
Connect Go release')
+    parser.add_argument('--tag', required=True, help='New tag version (e.g., 
v0.2.0)')
+    parser.add_argument('--prev-tag', required=True, help='Previous tag 
version (e.g., v0.1.0)')
+    parser.add_argument('--commit', required=True, help='Commit SHA for the 
tag')
+    parser.add_argument('--gpg-user', required=True, help='GPG user ID for 
signing')
+    parser.add_argument('--prerelease', action='store_true', help='Mark as 
pre-release')
+    parser.add_argument('--repo', default='apache/spark-connect-go', 
help='GitHub repository (owner/name)')
+    parser.add_argument('--token', help='GitHub token (or set GITHUB_TOKEN env 
var)')
+
+    args = parser.parse_args()
+
+    # Get GitHub token
+    github_token = args.token or os.environ.get('GITHUB_TOKEN')
+    if not github_token:
+        print("Error: GitHub token is required. Use --token or set 
GITHUB_TOKEN environment variable.")
+        sys.exit(1)
+
+    # Verify GPG key exists
+    if not verify_gpg_key(args.gpg_user):
+        print(f"Error: GPG key for user '{args.gpg_user}' not found or not 
usable")
+        sys.exit(1)
+
+    # Initialize GitHub client
+    github_client = Github(github_token)
+    repo = github_client.get_repo(args.repo)
+
+    print(f"Creating release for {args.repo}")
+    print(f"Tag: {args.tag}")
+    print(f"Commit: {args.commit}")
+    print(f"Previous tag: {args.prev_tag}")
+    print(f"GPG user: {args.gpg_user}")
+    print(f"Pre-release: {args.prerelease}")
+
+    # Step 1: Create and push tag
+    print("\n=== Step 1: Creating and pushing tag ===")
+    repo_path = os.getcwd()
+
+    try:
+        local_repo = git.Repo(repo_path)
+
+        # Create tag
+        new_tag = local_repo.create_tag(args.tag, ref=args.commit, 
message=f"Release {args.tag}")
+        print(f"Created tag {args.tag} at commit {args.commit}")
+
+        # Push tag
+        origin = local_repo.remote('origin')
+        origin.push(new_tag)
+        print(f"Pushed tag {args.tag} to GitHub")
+
+    except Exception as e:
+        print(f"Error creating/pushing tag: {e}")
+        sys.exit(1)
+
+    # Step 2: Get commits for release notes
+    print("\n=== Step 2: Generating release notes ===")
+    commits = get_commits_between_tags(repo_path, args.prev_tag, args.commit)
+    initial_release_notes = create_release_notes(commits)
+
+    # Step 3: Prompt user for release description
+    print("\n=== Step 3: Release description ===")
+    print("Initial release notes based on commits:")
+    print(initial_release_notes)
+    print("\nPlease enter the final release description (press Ctrl+D when 
done):")
+
+    lines = []
+    try:
+        while True:
+            line = input()
+            lines.append(line)
+    except EOFError:
+        pass
+
+    # Join the lines and add the initial release notes
+    final_release_notes = '\n'.join(lines).strip()
+    spacer = "\n\n" if final_release_notes else ""
+    final_release_notes += spacer + initial_release_notes
+
+    # Step 4: Create GitHub release
+    print("\n=== Step 4: Creating GitHub release ===")
+    try:
+        release = repo.create_git_release(
+            tag=args.tag,
+            name=f"Release {args.tag}",
+            message=final_release_notes,
+            draft=True,
+            prerelease=args.prerelease
+        )
+        print(f"Created draft release: {release.html_url}")
+
+    except Exception as e:
+        print(f"Error creating release: {e}")
+        sys.exit(1)
+
+    # Step 5: Download release artifacts
+    print("\n=== Step 5: Downloading release artifacts ===")
+
+    # GitHub automatically creates source archives
+    artifacts = [
+        f"{args.tag}.tar.gz",
+        f"{args.tag}.zip"
+    ]
+
+    with tempfile.TemporaryDirectory() as temp_dir:
+        downloaded_files = []
+
+        for artifact in artifacts:
+            # Construct download URL for source archive
+            download_url = 
f"https://github.com/{args.repo}/archive/refs/tags/{artifact}";
+            local_file = os.path.join(temp_dir, f"spark-connect-go-{artifact}")
+
+            try:
+                download_file(download_url, local_file)
+                downloaded_files.append(local_file)
+            except Exception as e:
+                print(f"Error downloading {artifact}: {e}")
+                continue
+
+        if not downloaded_files:
+            print("Error: No artifacts were downloaded")
+            sys.exit(1)
+
+        # Step 6: Sign artifacts
+        print("\n=== Step 6: Signing artifacts ===")
+        signatures = []
+
+        for file_path in downloaded_files:
+            try:
+                print(f"Signing {os.path.basename(file_path)}")
+                signature_path = sign_file(file_path, args.gpg_user)
+                signatures.append(signature_path)
+                print(f"Created signature: {os.path.basename(signature_path)}")
+
+            except Exception as e:
+                print(f"Error signing {file_path}: {e}")
+                continue
+
+        # Step 7: Verify signatures
+        print("\n=== Step 7: Verifying signatures ===")
+        for i, file_path in enumerate(downloaded_files):
+            if i < len(signatures):
+                signature_path = signatures[i]
+                if verify_signature(file_path, signature_path):
+                    print(f"✓ Signature verified for 
{os.path.basename(file_path)}")
+                else:
+                    print(f"✗ Signature verification failed for 
{os.path.basename(file_path)}")
+                    sys.exit(1)
+
+        # Step 8: Upload signatures to release
+        print("\n=== Step 8: Uploading signatures to release ===")
+        for signature_path in signatures:
+            try:
+                upload_release_asset(release, signature_path)
+                print(f"Uploaded {os.path.basename(signature_path)}")
+            except Exception as e:
+                print(f"Error uploading {signature_path}: {e}")
+                continue
+
+    print(f"\n=== Release created successfully ===")
+    print(f"Release URL: {release.html_url}")
+    print(f"Tag: {args.tag}")
+    print(f"Status: Draft")
+    print(f"Pre-release: {args.prerelease}")
+    print("\nNext steps:")
+    print("1. Review the release on GitHub")
+    print("2. Test the release artifacts")
+    print("3. Publish the release when ready")
+
+
+if __name__ == '__main__':
+    main()
\ No newline at end of file
diff --git a/dev/requirements.txt b/dev/requirements.txt
new file mode 100644
index 0000000..10aa47e
--- /dev/null
+++ b/dev/requirements.txt
@@ -0,0 +1,3 @@
+requests>=2.28.0
+PyGithub>=1.58.0
+gitpython>=3.1.30
\ No newline at end of file


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscr...@spark.apache.org
For additional commands, e-mail: commits-h...@spark.apache.org

Reply via email to