From 8f5ec2fef660ef10281f68dc393f5b399b3ee86f Mon Sep 17 00:00:00 2001
From: David Soria Parra <davidsp@anthropic.com>
Date: Tue, 14 Jan 2025 01:18:25 +0000
Subject: [PATCH] improve release workflow more

---
 .github/workflows/release.yml | 203 +++++++++-------------------------
 scripts/release.py            | 199 ++++++++++++++++-----------------
 2 files changed, 149 insertions(+), 253 deletions(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2bd97e67..0b9a0fff 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,10 +4,11 @@ on:
   workflow_dispatch:
 
 jobs:
-  detect-last-release:
+  create-metadata:
     runs-on: ubuntu-latest
     outputs:
-      last_release: ${{ steps.last-release.outputs.hash }}
+      hash: ${{ steps.last-release.outputs.hash }}
+      version: ${{ steps.create-version.outputs.version}}
     steps:
       - uses: actions/checkout@v4
         with:
@@ -20,23 +21,29 @@ jobs:
           echo "hash=${HASH}" >> $GITHUB_OUTPUT
           echo "Using last release hash: ${HASH}"
 
-  create-tag-name:
-    runs-on: ubuntu-latest
-    outputs:
-      tag_name: ${{ steps.last-release.outputs.tag}}
-    steps:
-      - name: Get last release hash
-        id: last-release
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+
+      - name: Create version name
+        id: create-version
+        run: echo "version=$(uv run --script scripts/release.py generate-version)" >> $GITHUB_OUTPUT
+
+      - name: Create notes
         run: |
-          DATE=$(date +%Y.%m.%d)
-          echo "tag=v${DATE}" >> $GITHUB_OUTPUT
-          echo "Using tag: v${DATE}"
+          HASH="${{ steps.last-release.outputs.hash }}"
+          uv run --script scripts/release.py generate-notes --directory src/ $HASH  > RELEASE_NOTES.md
+
+      - name: Release notes
+        uses: actions/upload-artifact@v4
+        with:
+          name: release-notes
+          path: RELEASE_NOTES.md
 
-  detect-packages:
-    needs: [detect-last-release]
+  update-packages:
+    needs: [create-metadata]
     runs-on: ubuntu-latest
     outputs:
-      packages: ${{ steps.find-packages.outputs.packages }}
+      changes_made: ${{ steps.commit.outputs.changes_made }}
     steps:
       - uses: actions/checkout@v4
         with:
@@ -45,52 +52,33 @@ jobs:
       - name: Install uv
         uses: astral-sh/setup-uv@v5
 
-      - name: Find packages
-        id: find-packages
-        working-directory: src
+      - name: Update packages
         run: |
-          cat << 'EOF' > find_packages.py
-          import json
-          import os
-          import subprocess
-          from itertools import chain
-          from pathlib import Path
+          HASH="${{ needs.detect-last-release.outputs.hash }}"
+          uv run --script scripts/release.py update-packages --directory src/ $HASH
 
-          packages = []
-
-          print("Starting package detection...")
-          print(f"Using LAST_RELEASE: {os.environ['LAST_RELEASE']}")
-
-          # Find all directories containing package.json or pyproject.toml
-          paths = chain(Path('.').glob('*/package.json'), Path('.').glob('*/pyproject.toml'))
-          for path in paths:
-                print(f"\nChecking path: {path}")
-                # Check for changes in .py or .ts files
-                # Run git diff from the specific directory
-                cmd = ['git', 'diff', '--name-only', f'{os.environ["LAST_RELEASE"]}..HEAD', '--', '.']
-                result = subprocess.run(cmd, capture_output=True, text=True, cwd=path.parent)
-
-                # Check if any .py or .ts files were changed
-                changed_files = result.stdout.strip().split('\n')
-                print(f"Changed files found: {changed_files}")
-
-                has_changes = any(f.endswith(('.py', '.ts')) for f in changed_files if f)
-                if has_changes:
-                    print(f"Adding package: {path.parent}")
-                    packages.append(str(path.parent))
-
-          print(f"\nFinal packages list: {packages}")
-
-          # Write output
-          with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
-              f.write(f"packages={json.dumps(packages)}\n")
-          EOF
+      - name: Configure git
+        run: |
+          git config --global user.name "GitHub Actions"
+          git config --global user.email "actions@github.com"
 
-          LAST_RELEASE=${{ needs.detect-last-release.outputs.last_release }} uv run --script --python 3.12 find_packages.py
+      - name: Commit changes
+        id: commit
+        run: |
+          VERSION="${{ needs.create-metadata.outputs.version }}"
+          git add -u
+          if git diff-index --quiet HEAD; then
+            echo "changes_made=false" >> $GITHUB_OUTPUT
+          else
+            git commit -m 'Automatic update of packages'
+            git tag -a "$VERSION" -m "Release $VERSION"
+            git push origin HEAD "$VERSION"
+            echo "changes_made=true" >> $GITHUB_OUTPUT
+          fi
 
-  create-tag:
-    needs: [detect-packages, create-tag-name]
-    if: fromJson(needs.detect-packages.outputs.packages)[0] != null
+  create-release:
+    needs: [update-packages, create-metadata]
+    if: needs.update-packages.outputs.changes_made == 'true'
     runs-on: ubuntu-latest
     environment: release
     permissions:
@@ -98,103 +86,16 @@ jobs:
     steps:
       - uses: actions/checkout@v4
 
-      - name: Install uv
-        uses: astral-sh/setup-uv@v5
-
-      - name: Install Node.js
-        uses: actions/setup-node@v4
+      - name: Download release notes
+        uses: actions/download-artifact@v4
         with:
-          node-version: '20'
-
-      - name: Update package versions and create tag
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        run: |
-          # Configure git
-          git config --global user.name "GitHub Actions"
-          git config --global user.email "actions@github.com"
-
-          # Get packages array and version
-          PACKAGES='${{ needs.detect-packages.outputs.packages }}'
-          VERSION="${{ needs.create-tag-name.outputs.tag_name }}"
-          VERSION_NO_V="${VERSION#v}"  # Remove 'v' prefix for package versions
-
-          # Create version update script
-          cat << 'EOF' > update_versions.py
-          import json
-          import os
-          import sys
-          import toml
-          from pathlib import Path
-
-          def update_package_json(path, version):
-              with open(path) as f:
-                  data = json.load(f)
-              data['version'] = version
-              with open(path, 'w') as f:
-                  json.dump(data, f, indent=2)
-                  f.write('\n')
-
-          def update_pyproject_toml(path, version):
-              data = toml.load(path)
-              if 'project' in data:
-                  data['project']['version'] = version
-              elif 'tool' in data and 'poetry' in data['tool']:
-                  data['tool']['poetry']['version'] = version
-              with open(path, 'w') as f:
-                  toml.dump(data, f)
-
-          packages = json.loads(os.environ['PACKAGES'])
-          version = os.environ['VERSION']
-
-          for package_dir in packages:
-              package_dir = Path('src') / package_dir
-              
-              # Update package.json if it exists
-              package_json = package_dir / 'package.json'
-              if package_json.exists():
-                  update_package_json(package_json, version)
-                  
-              # Update pyproject.toml if it exists
-              pyproject_toml = package_dir / 'pyproject.toml'
-              if pyproject_toml.exists():
-                  update_pyproject_toml(pyproject_toml, version)
-          EOF
-
-          # Install toml package for Python
-          uv pip install toml
-
-          # Update versions
-          PACKAGES="$PACKAGES" VERSION="$VERSION_NO_V" uv run update_versions.py
-
-          # Commit version updates
-          git add src/*/package.json src/*/pyproject.toml
-          git commit -m "chore: update package versions to $VERSION"
-
-          # Create and push tag
-          git tag -a "$VERSION" -m "Release $VERSION"
-          git push origin HEAD "$VERSION"
+          name: release-notes
 
       - name: Create release
         env:
           GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
         run: |
-          PACKAGES='${{ needs.detect-packages.outputs.packages }}'
-
-          if [ "$(echo "$PACKAGES" | jq 'length')" -gt 0 ]; then
-            # Generate comprehensive release notes
-            {
-              echo "# Release ${{ needs.create-tag-name.outputs.tag_name }}"
-              echo ""
-              echo "## Updated Packages"
-              echo "$PACKAGES" | jq -r '.[]' | while read -r package; do
-                echo "- $package"
-              done
-            } > notes.md
-            # Create GitHub release
-            gh release create "${{ needs.create-tag-name.outputs.tag_name }}" \
-              --title "Release ${{ needs.create-tag-name.outputs.tag_name }}" \
-              --notes-file notes.md
-          else
-            echo "No packages need release"
-          fi
+          VERSION="${{ needs.create-metadata.outputs.version }}"
+          gh release create "$VERSION" \
+            --title "Release $VERSION" \
+            --notes-file RELEASE_NOTES.md
diff --git a/scripts/release.py b/scripts/release.py
index ace528eb..90ee7e97 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env uv run --script
 # /// script
-# requires-python = ">=3.11"
+# requires-python = ">=3.12"
 # dependencies = [
 #     "click>=8.1.8",
 #     "tomlkit>=0.13.2"
@@ -14,8 +14,8 @@
 import tomlkit
 import datetime
 import subprocess
-from enum import Enum
-from typing import Any, NewType
+from dataclasses import dataclass
+from typing import Any, Iterator, NewType, Protocol
 
 
 Version = NewType("Version", str)
@@ -50,26 +50,57 @@ def convert(
 
 GIT_HASH = GitHashParamType()
 
-
-class PackageType(Enum):
-    NPM = 1
-    PYPI = 2
-
-    @classmethod
-    def from_path(cls, directory: Path) -> "PackageType":
-        if (directory / "package.json").exists():
-            return cls.NPM
-        elif (directory / "pyproject.toml").exists():
-            return cls.PYPI
-        else:
-            raise Exception("No package.json or pyproject.toml found")
-
-
-def get_changes(path: Path, git_hash: str) -> bool:
+class Package(Protocol):
+    path: Path
+
+    def package_name(self) -> str:
+        ...
+
+    def update_version(self, version: Version) -> None:
+        ...
+
+@dataclass
+class NpmPackage:
+    path: Path
+
+    def package_name(self) -> str:
+        with open(self.path / "package.json", "rb") as f:
+            return json.load(f)["name"]
+
+    def update_version(self, version: Version):
+        with open(self.path / "package.json", "rb+") as f:
+            data = json.load(f)
+            data["version"] = version
+            f.seek(0)
+            json.dump(data, f, indent=2)
+            f.truncate()
+
+@dataclass
+class PyPiPackage:
+    path: Path
+
+    def package_name(self) -> str:
+        with open(self.path / "pyproject.toml") as f:
+            toml_data = tomlkit.parse(f.read())
+            name = toml_data.get("project", {}).get("name")
+            if not name:
+                raise Exception("No name in pyproject.toml project section")
+            return str(name)
+
+    def update_version(self, version: Version):
+        # Update version in pyproject.toml
+        with open(self.path / "pyproject.toml") as f:
+            data = tomlkit.parse(f.read())
+            data["project"]["version"] = version
+
+        with open(self.path / "pyproject.toml", "w") as f:
+            f.write(tomlkit.dumps(data))
+
+def has_changes(path: Path, git_hash: str) -> bool:
     """Check if any files changed between current state and git hash"""
     try:
         output = subprocess.run(
-            ["git", "diff", "--name-only", git_hash, "--", path],
+            ["git", "diff", "--name-only", git_hash, "--", '.'],
             cwd=path,
             check=True,
             capture_output=True,
@@ -83,99 +114,63 @@ def get_changes(path: Path, git_hash: str) -> bool:
         return False
 
 
-def get_package_name(path: Path, pkg_type: PackageType) -> str:
-    """Get package name from package.json or pyproject.toml"""
-    match pkg_type:
-        case PackageType.NPM:
-            with open(path / "package.json", "rb") as f:
-                return json.load(f)["name"]
-        case PackageType.PYPI:
-            with open(path / "pyproject.toml") as f:
-                toml_data = tomlkit.parse(f.read())
-                name = toml_data.get("project", {}).get("name")
-                if not name:
-                    raise Exception("No name in pyproject.toml project section")
-                return str(name)
-
-
 def generate_version() -> Version:
     """Generate version based on current date"""
     now = datetime.datetime.now()
     return Version(f"{now.year}.{now.month}.{now.day}")
 
 
-def publish_package(
-    path: Path, pkg_type: PackageType, version: Version, dry_run: bool = False
-):
-    """Publish package based on type"""
-    try:
-        match pkg_type:
-            case PackageType.NPM:
-                # Update version in package.json
-                with open(path / "package.json", "rb+") as f:
-                    data = json.load(f)
-                    data["version"] = version
-                    f.seek(0)
-                    json.dump(data, f, indent=2)
-                    f.truncate()
-
-                if not dry_run:
-                    # Publish to npm
-                    subprocess.run(["npm", "publish"], cwd=path, check=True)
-            case PackageType.PYPI:
-                # Update version in pyproject.toml
-                with open(path / "pyproject.toml") as f:
-                    data = tomlkit.parse(f.read())
-                    data["project"]["version"] = version
-
-                with open(path / "pyproject.toml", "w") as f:
-                    f.write(tomlkit.dumps(data))
-
-                if not dry_run:
-                    # Build and publish to PyPI
-                    subprocess.run(["uv", "build"], cwd=path, check=True)
-                    subprocess.run(
-                        ["uv", "publish"],
-                        cwd=path,
-                        check=True,
-                    )
-    except Exception as e:
-        raise Exception(f"Failed to publish: {e}") from e
-
-
-@click.command()
-@click.argument("directory", type=click.Path(exists=True, path_type=Path))
+def find_changed_packages(directory: Path, git_hash: GitHash) -> Iterator[Package]:
+    for path in Path('.').glob('*/package.json'):
+        if has_changes(path, git_hash):
+            yield NpmPackage(path.parent)
+    for path in Path('.').glob('*/pyproject.toml'):
+        if has_changes(path, git_hash):
+            yield PyPiPackage(path.parent)
+
+
+@click.group()
+def cli():
+    pass
+
+@cli.command('update-packages')
+@click.option("directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd())
 @click.argument("git_hash", type=GIT_HASH)
-@click.option(
-    "--dry-run", is_flag=True, help="Update version numbers but don't publish"
-)
-def main(directory: Path, git_hash: GitHash, dry_run: bool) -> int:
-    """Release package if changes detected"""
+def update_packages(directory: Path, git_hash: GitHash) -> int:
     # Detect package type
-    try:
-        path = directory.resolve(strict=True)
-        pkg_type = PackageType.from_path(path)
-    except Exception as e:
-        return 1
+    path = directory.resolve(strict=True)
+    version = generate_version()
 
-    # Check for changes
-    if not get_changes(path, git_hash):
-        return 0
+    for package in find_changed_packages(path, git_hash):
+        name = package.package_name()
+        package.update_version(version)
 
-    try:
-        # Generate version and publish
-        version = generate_version()
-        name = get_package_name(path, pkg_type)
-
-        publish_package(path, pkg_type, version, dry_run)
-        if not dry_run:
-            click.echo(f"{name}@{version}")
-        else:
-            click.echo(f"Dry run: Would have published {name}@{version}")
-        return 0
-    except Exception as e:
-        return 1
+        click.echo(f"{name}@{version}")
+
+    return 0
 
+@cli.command('generate-notes')
+@click.option("directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd())
+@click.argument("git_hash", type=GIT_HASH)
+def generate_notes(directory: Path, git_hash: GitHash) -> int:
+    # Detect package type
+    path = directory.resolve(strict=True)
+    version = generate_version()
+
+    click.echo(f"# Release : v{version}")
+    click.echo("")
+    click.echo("## Updated packages")
+    for package in find_changed_packages(path, git_hash):
+        name = package.package_name()
+        click.echo(f"- {name}@{version}")
+
+    return 0
+
+@cli.command('generate-version')
+def generate_version() -> int:
+    # Detect package type
+    click.echo(generate_version())
+    return 0
 
 if __name__ == "__main__":
     sys.exit(main())