improve release workflow more

This commit is contained in:
David Soria Parra
2025-01-14 01:18:25 +00:00
parent 9edf9fcaf0
commit 8e944369b7
2 changed files with 159 additions and 249 deletions

View File

@@ -4,10 +4,11 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
detect-last-release: create-metadata:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
last_release: ${{ steps.last-release.outputs.hash }} hash: ${{ steps.last-release.outputs.hash }}
version: ${{ steps.create-version.outputs.version}}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -20,23 +21,33 @@ jobs:
echo "hash=${HASH}" >> $GITHUB_OUTPUT echo "hash=${HASH}" >> $GITHUB_OUTPUT
echo "Using last release hash: ${HASH}" echo "Using last release hash: ${HASH}"
create-tag-name: - name: Install uv
runs-on: ubuntu-latest uses: astral-sh/setup-uv@v5
outputs:
tag_name: ${{ steps.last-release.outputs.tag}}
steps:
- name: Get last release hash
id: last-release
run: |
DATE=$(date +%Y.%m.%d)
echo "tag=v${DATE}" >> $GITHUB_OUTPUT
echo "Using tag: v${DATE}"
detect-packages: - name: Create version name
needs: [detect-last-release] id: create-version
run: |
VERSION=$(uv run --script scripts/release.py generate-version)
echo "version $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create notes
run: |
HASH="${{ steps.last-release.outputs.hash }}"
uv run --script scripts/release.py generate-notes --directory src/ $HASH > RELEASE_NOTES.md
cat RELEASE_NOTES.md
- name: Release notes
uses: actions/upload-artifact@v4
with:
name: release-notes
path: RELEASE_NOTES.md
update-packages:
needs: [create-metadata]
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
packages: ${{ steps.find-packages.outputs.packages }} changes_made: ${{ steps.commit.outputs.changes_made }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -45,52 +56,33 @@ jobs:
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v5
- name: Find packages - name: Update packages
id: find-packages
working-directory: src
run: | run: |
cat << 'EOF' > find_packages.py HASH="${{ needs.create-metadata.outputs.hash }}"
import json uv run --script scripts/release.py update-packages --directory src/ $HASH
import os
import subprocess
from itertools import chain
from pathlib import Path
packages = [] - name: Configure git
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
print("Starting package detection...") - name: Commit changes
print(f"Using LAST_RELEASE: {os.environ['LAST_RELEASE']}") 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 "$VERSION"
echo "changes_made=true" >> $GITHUB_OUTPUT
fi
# Find all directories containing package.json or pyproject.toml create-release:
paths = chain(Path('.').glob('*/package.json'), Path('.').glob('*/pyproject.toml')) needs: [update-packages, create-metadata]
for path in paths: if: needs.update-packages.outputs.changes_made == 'true'
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
LAST_RELEASE=${{ needs.detect-last-release.outputs.last_release }} uv run --script --python 3.12 find_packages.py
create-tag:
needs: [detect-packages, create-tag-name]
if: fromJson(needs.detect-packages.outputs.packages)[0] != null
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: release environment: release
permissions: permissions:
@@ -98,103 +90,16 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv - name: Download release notes
uses: astral-sh/setup-uv@v5 uses: actions/download-artifact@v4
- name: Install Node.js
uses: actions/setup-node@v4
with: with:
node-version: '20' name: release-notes
- 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: Create release - name: Create release
env: env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
PACKAGES='${{ needs.detect-packages.outputs.packages }}' VERSION="${{ needs.create-metadata.outputs.version }}"
gh release create "$VERSION" \
if [ "$(echo "$PACKAGES" | jq 'length')" -gt 0 ]; then --title "Release $VERSION" \
# Generate comprehensive release notes --notes-file RELEASE_NOTES.md
{
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

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env uv run --script #!/usr/bin/env uv run --script
# /// script # /// script
# requires-python = ">=3.11" # requires-python = ">=3.12"
# dependencies = [ # dependencies = [
# "click>=8.1.8", # "click>=8.1.8",
# "tomlkit>=0.13.2" # "tomlkit>=0.13.2"
@@ -14,8 +14,8 @@ import json
import tomlkit import tomlkit
import datetime import datetime
import subprocess import subprocess
from enum import Enum from dataclasses import dataclass
from typing import Any, NewType from typing import Any, Iterator, NewType, Protocol
Version = NewType("Version", str) Version = NewType("Version", str)
@@ -51,25 +51,58 @@ class GitHashParamType(click.ParamType):
GIT_HASH = GitHashParamType() GIT_HASH = GitHashParamType()
class PackageType(Enum): class Package(Protocol):
NPM = 1 path: Path
PYPI = 2
@classmethod def package_name(self) -> str: ...
def from_path(cls, directory: Path) -> "PackageType":
if (directory / "package.json").exists(): def update_version(self, version: Version) -> None: ...
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: @dataclass
class NpmPackage:
path: Path
def package_name(self) -> str:
with open(self.path / "package.json", "r") as f:
return json.load(f)["name"]
def update_version(self, version: Version):
with open(self.path / "package.json", "r+") 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: GitHash) -> bool:
"""Check if any files changed between current state and git hash""" """Check if any files changed between current state and git hash"""
try: try:
output = subprocess.run( output = subprocess.run(
["git", "diff", "--name-only", git_hash, "--", path], ["git", "diff", "--name-only", git_hash, "--", "."],
cwd=path, cwd=path,
check=True, check=True,
capture_output=True, capture_output=True,
@@ -77,105 +110,77 @@ def get_changes(path: Path, git_hash: str) -> bool:
) )
changed_files = [Path(f) for f in output.stdout.splitlines()] changed_files = [Path(f) for f in output.stdout.splitlines()]
relevant_files = [f for f in changed_files if f.suffix in ['.py', '.ts']] relevant_files = [f for f in changed_files if f.suffix in [".py", ".ts"]]
return len(relevant_files) >= 1 return len(relevant_files) >= 1
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return False return False
def get_package_name(path: Path, pkg_type: PackageType) -> str: def gen_version() -> Version:
"""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""" """Generate version based on current date"""
now = datetime.datetime.now() now = datetime.datetime.now()
return Version(f"{now.year}.{now.month}.{now.day}") return Version(f"{now.year}.{now.month}.{now.day}")
def publish_package( def find_changed_packages(directory: Path, git_hash: GitHash) -> Iterator[Package]:
path: Path, pkg_type: PackageType, version: Version, dry_run: bool = False for path in directory.glob("*/package.json"):
): if has_changes(path.parent, git_hash):
"""Publish package based on type""" yield NpmPackage(path.parent)
try: for path in directory.glob("*/pyproject.toml"):
match pkg_type: if has_changes(path.parent, git_hash):
case PackageType.NPM: yield PyPiPackage(path.parent)
# 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.group()
@click.argument("directory", type=click.Path(exists=True, path_type=Path)) def cli():
@click.argument("git_hash", type=GIT_HASH) pass
@cli.command("update-packages")
@click.option( @click.option(
"--dry-run", is_flag=True, help="Update version numbers but don't publish" "--directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd()
) )
def main(directory: Path, git_hash: GitHash, dry_run: bool) -> int: @click.argument("git_hash", type=GIT_HASH)
"""Release package if changes detected""" def update_packages(directory: Path, git_hash: GitHash) -> int:
# Detect package type # Detect package type
try: path = directory.resolve(strict=True)
path = directory.resolve(strict=True) version = gen_version()
pkg_type = PackageType.from_path(path)
except Exception as e:
return 1
# Check for changes for package in find_changed_packages(path, git_hash):
if not get_changes(path, git_hash): name = package.package_name()
return 0 package.update_version(version)
try: click.echo(f"{name}@{version}")
# Generate version and publish
version = generate_version()
name = get_package_name(path, pkg_type)
publish_package(path, pkg_type, version, dry_run) return 0
if not dry_run:
click.echo(f"{name}@{version}")
else: @cli.command("generate-notes")
click.echo(f"Dry run: Would have published {name}@{version}") @click.option(
return 0 "--directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd()
except Exception as e: )
return 1 @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 = gen_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(gen_version())
return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(cli())