From 26d3dd4760063aa5e3e83a8155cac73fbe0d6c47 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Fri, 10 Jan 2025 21:05:09 +0000 Subject: [PATCH 1/4] feat: add weekly release automation --- .github/workflows/weekly-release.yml | 102 +++++++++++++++ scripts/release.py | 179 +++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 .github/workflows/weekly-release.yml create mode 100755 scripts/release.py diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/weekly-release.yml new file mode 100644 index 00000000..e4952bd4 --- /dev/null +++ b/.github/workflows/weekly-release.yml @@ -0,0 +1,102 @@ +name: Weekly Release + +on: + schedule: + # Run every Monday at 9:00 UTC + - cron: '0 9 * * 1' + # Allow manual trigger for testing + workflow_dispatch: + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + last_release: ${{ steps.last-release.outputs.hash }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Find package directories + id: set-matrix + run: | + DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]') + echo "matrix=${DIRS}" >> $GITHUB_OUTPUT + + - name: Get last release hash + id: last-release + run: | + HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1") + echo "hash=${HASH}" >> $GITHUB_OUTPUT + + release: + needs: prepare + runs-on: ubuntu-latest + strategy: + matrix: + directory: ${{ fromJson(needs.prepare.outputs.matrix) }} + fail-fast: false + permissions: + contents: write + packages: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v5 + + - name: Setup Node.js + if: endsWith(matrix.directory, 'package.json') + uses: actions/setup-node@v4 + with: + node-version: '18' + registry-url: 'https://registry.npmjs.org' + + - name: Setup Python + if: endsWith(matrix.directory, 'pyproject.toml') + run: uv python install + + - name: Release package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: uv run --script scripts/release.py "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" >> "$GITHUB_OUTPUT" + + create-release: + needs: [prepare, release] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Create Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Check if there's output from release step + if [ -s "$GITHUB_OUTPUT" ]; then + DATE=$(date +%Y.%m.%d) + + # Create git tag + git tag -s -a -m"automated release v${DATE}" "v${DATE}" + git push origin "v${DATE}" + + # Create release notes + echo "# Release ${DATE}" > notes.md + echo "" >> notes.md + echo "## Updated Packages" >> notes.md + + # Read updated packages from github output + while IFS= read -r line; do + echo "- ${line}" >> notes.md + done < "$GITHUB_OUTPUT" + + # Create GitHub release + gh release create "v${DATE}" \ + --title "Release ${DATE}" \ + --notes-file notes.md + fi diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 00000000..0c1eb4da --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,179 @@ +#!/usr/bin/env uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "click>=8.1.8", +# "tomlkit>=0.13.2" +# ] +# /// +import sys +import re +import click +from pathlib import Path +import json +import tomlkit +import datetime +import subprocess +from enum import Enum +from typing import Any, NewType + + +Version = NewType("Version", str) +GitHash = NewType("GitHash", str) + + +class GitHashParamType(click.ParamType): + name = "git_hash" + + def convert( + self, value: Any, param: click.Parameter | None, ctx: click.Context | None + ) -> GitHash | None: + if value is None: + return None + + if not (8 <= len(value) <= 40): + self.fail(f"Git hash must be between 8 and 40 characters, got {len(value)}") + + if not re.match(r"^[0-9a-fA-F]+$", value): + self.fail("Git hash must contain only hex digits (0-9, a-f)") + + try: + # Verify hash exists in repo + subprocess.run( + ["git", "rev-parse", "--verify", value], check=True, capture_output=True + ) + except subprocess.CalledProcessError: + self.fail(f"Git hash {value} not found in repository") + + return GitHash(value.lower()) + + +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: + """Check if any files changed between current state and git hash""" + try: + output = subprocess.run( + ["git", "diff", "--name-only", git_hash, "--", path], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + + changed_files = [Path(f) for f in output.stdout.splitlines()] + relevant_files = [f for f in changed_files if f.suffix in ['.py', '.ts']] + return len(relevant_files) >= 1 + except subprocess.CalledProcessError: + 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", "--username", "__token__"], + 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)) +@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""" + # Detect package type + try: + path = directory.resolve(strict=True) + pkg_type = PackageType.from_path(path) + except Exception as e: + return 1 + + # Check for changes + if not get_changes(path, git_hash): + return 0 + + 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}") + return 0 + except Exception as e: + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 6d36b5a1ff49b091845ec141048b6f5e45cfdc84 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 11:23:01 +0000 Subject: [PATCH 2/4] feat: add daily release check workflow --- .github/workflows/daily-release-check.yml | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/daily-release-check.yml diff --git a/.github/workflows/daily-release-check.yml b/.github/workflows/daily-release-check.yml new file mode 100644 index 00000000..135e76d7 --- /dev/null +++ b/.github/workflows/daily-release-check.yml @@ -0,0 +1,28 @@ +name: Daily Release Check + +on: + schedule: + - cron: '0 0 * * *' # Run at midnight UTC daily + workflow_dispatch: # Allow manual triggers + +jobs: + check-releases: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need full history for git log + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install semver PyGithub rich toml click + + - name: Run release check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/weekly-release.py --dry-run \ No newline at end of file From 0989068ef1d62f1eb44eb00089001fb3511337ad Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 11:34:12 +0000 Subject: [PATCH 3/4] feat: enhance release automation with daily checks --- .github/workflows/daily-release-check.yml | 64 +++++++++++++++++------ scripts/release.py | 2 + 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/.github/workflows/daily-release-check.yml b/.github/workflows/daily-release-check.yml index 135e76d7..128fd557 100644 --- a/.github/workflows/daily-release-check.yml +++ b/.github/workflows/daily-release-check.yml @@ -2,27 +2,59 @@ name: Daily Release Check on: schedule: - - cron: '0 0 * * *' # Run at midnight UTC daily - workflow_dispatch: # Allow manual triggers + # Run every day at 9:00 UTC + - cron: '0 9 * * *' + # Allow manual trigger for testing + workflow_dispatch: jobs: - check-releases: + prepare: runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + last_release: ${{ steps.last-release.outputs.hash }} steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 # Need full history for git log - - - name: Set up Python - uses: actions/setup-python@v4 + fetch-depth: 0 + + - name: Find package directories + id: set-matrix + run: | + DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]') + echo "matrix=${DIRS}" >> $GITHUB_OUTPUT + + - name: Get last release hash + id: last-release + run: | + HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1") + echo "hash=${HASH}" >> $GITHUB_OUTPUT + + check-release: + needs: prepare + runs-on: ubuntu-latest + strategy: + matrix: + directory: ${{ fromJson(needs.prepare.outputs.matrix) }} + fail-fast: false + + steps: + - uses: actions/checkout@v4 with: - python-version: '3.11' - - - name: Install dependencies + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v5 + + - name: Setup Node.js + if: endsWith(matrix.directory, 'package.json') + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Setup Python + if: endsWith(matrix.directory, 'pyproject.toml') + run: uv python install + + - name: Check release run: | - python -m pip install semver PyGithub rich toml click - - - name: Run release check - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python scripts/weekly-release.py --dry-run \ No newline at end of file + uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" diff --git a/scripts/release.py b/scripts/release.py index 0c1eb4da..0853d36e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -170,6 +170,8 @@ def main(directory: Path, git_hash: GitHash, dry_run: bool) -> int: 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} if this was a real release") return 0 except Exception as e: return 1 From 9db47b20e7a7f39be4d30f27c756a8a0cc433a42 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 11:35:46 +0000 Subject: [PATCH 4/4] feat: rename --- .../{daily-release-check.yml => release-check.yml} | 3 --- .github/workflows/{weekly-release.yml => release.yml} | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) rename .github/workflows/{daily-release-check.yml => release-check.yml} (95%) rename .github/workflows/{weekly-release.yml => release.yml} (97%) diff --git a/.github/workflows/daily-release-check.yml b/.github/workflows/release-check.yml similarity index 95% rename from .github/workflows/daily-release-check.yml rename to .github/workflows/release-check.yml index 128fd557..5350157b 100644 --- a/.github/workflows/daily-release-check.yml +++ b/.github/workflows/release-check.yml @@ -1,9 +1,6 @@ name: Daily Release Check on: - schedule: - # Run every day at 9:00 UTC - - cron: '0 9 * * *' # Allow manual trigger for testing workflow_dispatch: diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/release.yml similarity index 97% rename from .github/workflows/weekly-release.yml rename to .github/workflows/release.yml index e4952bd4..252c89fd 100644 --- a/.github/workflows/weekly-release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ -name: Weekly Release +name: Daily Release on: schedule: - # Run every Monday at 9:00 UTC - - cron: '0 9 * * 1' + # Run every day at 9:00 UTC + - cron: '0 9 * * *' # Allow manual trigger for testing workflow_dispatch: