Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add weekly release automation #465

Merged
merged 4 commits into from
Jan 13, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: add weekly release automation
dsp-ant committed Jan 10, 2025
commit 26d3dd4760063aa5e3e83a8155cac73fbe0d6c47
102 changes: 102 additions & 0 deletions .github/workflows/weekly-release.yml
Original file line number Diff line number Diff line change
@@ -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
179 changes: 179 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -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())