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
Show file tree
Hide file tree
Changes from all commits
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
57 changes: 57 additions & 0 deletions .github/workflows/release-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Daily Release Check

on:
# 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

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:
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: |
uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}"
102 changes: 102 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Daily Release

on:
schedule:
# Run every day at 9:00 UTC
- cron: '0 9 * * *'
# 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
181 changes: 181 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/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}")
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


if __name__ == "__main__":
sys.exit(main())
Loading