diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e01b9a5..2229b47 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -103,6 +103,8 @@ jobs: run: bash actions/test-sdist/test_check_test_files.sh - name: Test parse_diff.sh run: bash actions/pre-commit-autoupdate/test_parse_diff.sh + - name: Test diff_lock.py + run: bash actions/poetry-lock-update/test_diff_lock.sh test_pre_commit_autoupdate: if: github.event_name != 'schedule' || github.repository == 'Calysto/maintainer_tools' @@ -116,6 +118,19 @@ jobs: with: dry-run: "true" + test_poetry_lock_update: + if: github.event_name != 'schedule' || github.repository == 'Calysto/maintainer_tools' + name: Test Poetry Lock Update + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: $/actions/base-setup + - uses: $/actions/poetry-lock-update + with: + dry-run: "true" + test_pre_commit_run: if: github.event_name != 'schedule' || github.repository == 'Calysto/maintainer_tools' name: Test Pre-commit Run @@ -155,6 +170,7 @@ jobs: - test_release - test_scripts - test_pre_commit_autoupdate + - test_poetry_lock_update - test_pre_commit_run - static runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index cee6b0e..1e7d098 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,6 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# CLAUDE.md is an intentional, tracked project file — override the user's global ~/.gitignore exclusion +!CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..21be432 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +just install # Install dependencies via Poetry +just test # Run all tests +just test tests/test_foo.py::test_bar # Run a single test +just pre-commit # Run pre-commit hooks on all files +just pre-commit --hook-stage=manual # Also run actionlint on workflow files +``` + +## Architecture + +This repo provides reusable GitHub Actions for Calysto Python packages, consumed via the `v1` floating tag: + +```yaml +uses: calysto/maintainer_tools/actions/@v1 +``` + +### Actions + +Each action lives in `actions//action.yml`. The actions are: + +- **`base-setup`** — Sets up Python (auto-detects minimum version from `pyproject.toml` if unspecified), Poetry, and `just` with OS-keyed cache. Must be called before `release`, `test-minimum-versions`, and `test-sdist`. +- **`poetry-lock-update`** — Runs `poetry update` under a minimum-release-age cooldown (`POETRY_SOLVER_MIN_RELEASE_AGE`, default 7 days) and opens a pull request with the `poetry.lock` diff. Requires a GitHub App (`APP_ID` / `APP_PRIVATE_KEY`) for authenticated pushes. Must be called after `base-setup`. +- **`enforce-label`** — Wraps `yogevbd/enforce-label-action`; requires one of: `bug`, `enhancement`, `dependencies`, `maintenance`, `documentation`. +- **`release`** — Full release pipeline: bumps version via Poetry, generates and writes CHANGELOG.md, commits and pushes, creates GitHub release, then bumps to next `.dev` version using `actions/release/bump_dev.py`. Supports dry-run. Requires a GitHub App (`APP_ID` / `APP_PRIVATE_KEY`) for authenticated pushes. +- **`test-minimum-versions`** — Rewrites `pyproject.toml` to pin all deps to their minimum declared versions, then runs the test suite. +- **`test-sdist`** — Downloads the `Packages` artifact from `hynek/build-and-inspect-python-package`, unpacks the sdist, and runs the test suite from within it. + +### Release workflow + +`.github/workflows/release.yml` orchestrates: + +1. **`release`** job — runs `./actions/release`, outputs the new tag +1. **`build-package`** job — checks out the release tag and builds via `hynek/build-and-inspect-python-package` as a release-time sanity check (this package is not published to PyPI) +1. **`update-v1-tag`** job — moves the `v1` floating tag to the new release commit; skipped for pre-releases (tags containing `a`, `b`, `rc`, or `dev`) + +`workflow_dispatch` runs use `dry_run: false` by default; scheduled runs always use `dry_run: true`. + +### PRs + +Always target the upstream repo: `--repo Calysto/maintainer_tools --head :`. diff --git a/README.md b/README.md index 6a071be..46e4633 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,61 @@ jobs: ______________________________________________________________________ +### `poetry-lock-update` + +Runs `poetry update` under a minimum-release-age cooldown and opens a pull request with the `poetry.lock` changes. Requires `base-setup` to run before this action. Optionally generates a GitHub App token for authenticated pushes. + +**Inputs** + +| Name | Required | Default | Description | +|------|----------|---------|-------------| +| `app-id` | No | `""` | GitHub App ID for authenticated pushes. Falls back to `github.token` if not provided. | +| `app-private-key` | No | `""` | GitHub App private key for authenticated pushes. | +| `min-release-age-days` | No | `"7"` | Minimum release age in days before Poetry's resolver will consider a version. | +| `branch` | No | `"poetry-lock-update"` | Branch name for the update pull request. | +| `labels` | No | `"maintenance"` | Labels to apply to the pull request. | +| `dry-run` | No | `"false"` | If `"true"`, passes `--dry-run` to `gh pr create` (no PR is actually opened). | + +**Usage** + +```yaml +- uses: actions/checkout@v6 + with: + persist-credentials: false +- uses: calysto/maintainer_tools/actions/base-setup@v1 +- uses: calysto/maintainer_tools/actions/poetry-lock-update@v1 + with: + app-id: ${{ vars.APP_ID }} + app-private-key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +Typically used in a scheduled workflow: + +```yaml +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 6am + +permissions: + pull-requests: write + +jobs: + lock-update: + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: calysto/maintainer_tools/actions/base-setup@v1 + - uses: calysto/maintainer_tools/actions/poetry-lock-update@v1 + with: + app-id: ${{ vars.APP_ID }} + app-private-key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +______________________________________________________________________ + ### `enforce-label` Enforces that every PR has at least one of the required labels: `bug`, `enhancement`, `dependencies`, `maintenance`, `documentation`. diff --git a/actions/poetry-lock-update/action.yml b/actions/poetry-lock-update/action.yml new file mode 100644 index 0000000..27788a9 --- /dev/null +++ b/actions/poetry-lock-update/action.yml @@ -0,0 +1,101 @@ +name: Poetry Lock Update +description: Runs `poetry update` under a minimum-release-age cooldown and opens a pull request with the lock file changes + +inputs: + app-id: + required: false + default: "" + description: "GitHub App ID for authenticated pushes (optional; falls back to github.token)" + app-private-key: + required: false + default: "" + description: "GitHub App private key for authenticated pushes" + min-release-age-days: + required: false + default: "7" + description: "Minimum release age in days before Poetry's resolver will consider a version (sets POETRY_SOLVER_MIN_RELEASE_AGE)" + branch: + required: false + default: "poetry-lock-update" + description: "Branch name for the update pull request" + labels: + required: false + default: "maintenance" + description: "Labels to apply to the pull request" + dry-run: + required: false + default: "false" + description: "If 'true', pass --dry-run to gh pr create (skips actual PR creation)" + +runs: + using: composite + steps: + - name: Generate app token + id: app-token + if: inputs.app-id != '' && inputs.app-private-key != '' + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.app-private-key }} + permission-contents: write + permission-pull-requests: write + + - name: Save current lock file + shell: bash + run: cp poetry.lock "${{ runner.temp }}/poetry.lock.before" + + - name: Run poetry update + shell: bash + env: + POETRY_SOLVER_MIN_RELEASE_AGE: ${{ inputs.min-release-age-days }} + run: poetry update --no-interaction --lock + + - name: Create pull request + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + BRANCH: ${{ inputs.branch }} + LABELS: ${{ inputs.labels }} + DRY_RUN: ${{ inputs.dry-run }} + OLD_LOCK: ${{ runner.temp }}/poetry.lock.before + run: | + if git diff --quiet poetry.lock; then + echo "No changes detected, skipping PR creation" + exit 0 + fi + + # Generate random suffix for branch name + SUFFIX=$(openssl rand -hex 4) + FULL_BRANCH="${BRANCH}-${SUFFIX}" + + # diff_lock.py needs tomllib (Python 3.11+) or its tomli fallback; + # install tomli on older interpreters where neither is present. + python3 -c "import tomllib" 2>/dev/null || pip install --quiet tomli + + UPDATES=$(python3 "${{ github.action_path }}/diff_lock.py" "$OLD_LOCK" poetry.lock) + + BODY="" + if [ -n "$UPDATES" ]; then + BODY="## Updated packages"$'\n\n'"${UPDATES}" + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git checkout -B "$FULL_BRANCH" + git add poetry.lock + git commit -m "chore: update poetry.lock" + if [ "$DRY_RUN" != "true" ]; then + git push origin "$FULL_BRANCH" + fi + DRY_RUN_FLAG="" + if [ "$DRY_RUN" = "true" ]; then + DRY_RUN_FLAG="--dry-run" + fi + # shellcheck disable=SC2086 + gh pr create \ + --title "chore: update poetry.lock" \ + --body "$BODY" \ + --label "$LABELS" \ + --head "$FULL_BRANCH" \ + $DRY_RUN_FLAG diff --git a/actions/poetry-lock-update/diff_lock.py b/actions/poetry-lock-update/diff_lock.py new file mode 100644 index 0000000..bfb110a --- /dev/null +++ b/actions/poetry-lock-update/diff_lock.py @@ -0,0 +1,44 @@ +"""Diff two poetry.lock files and print a markdown list of package version changes.""" + +import sys + +try: + import tomllib +except ImportError: + import tomli as tomllib # type: ignore[no-redef] + + +def load_versions(path: str) -> dict[str, str]: + with open(path, "rb") as f: + data = tomllib.load(f) + return {pkg["name"]: pkg["version"] for pkg in data.get("package", [])} + + +def diff_versions(old: dict[str, str], new: dict[str, str]) -> list[str]: + lines = [] + for name in sorted(set(old) | set(new)): + old_version = old.get(name) + new_version = new.get(name) + if old_version == new_version: + continue + if old_version is None: + lines.append(f"- {name}: added `{new_version}`") + elif new_version is None: + lines.append(f"- {name}: removed `{old_version}`") + else: + lines.append(f"- {name}: `{old_version}` → `{new_version}`") + return lines + + +def main() -> None: + if len(sys.argv) != 3: + print("Usage: diff_lock.py ", file=sys.stderr) + sys.exit(2) + old = load_versions(sys.argv[1]) + new = load_versions(sys.argv[2]) + for line in diff_versions(old, new): + print(line) + + +if __name__ == "__main__": + main() diff --git a/actions/poetry-lock-update/test_diff_lock.sh b/actions/poetry-lock-update/test_diff_lock.sh new file mode 100755 index 0000000..6cd7f63 --- /dev/null +++ b/actions/poetry-lock-update/test_diff_lock.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/diff_lock.py" +FAIL=0 +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +check() { + local desc="$1" + local expected="$2" + local actual="$3" + if [ "$actual" = "$expected" ]; then + echo "OK: $desc" + else + echo "FAIL: $desc" + echo " expected: $expected" + echo " actual: $actual" + FAIL=1 + fi +} + +cat > "$TMPDIR/old.lock" <<'EOF' +[[package]] +name = "black" +version = "23.1.0" + +[[package]] +name = "flake8" +version = "6.0.0" + +[[package]] +name = "requests" +version = "2.31.0" +EOF + +cat > "$TMPDIR/new.lock" <<'EOF' +[[package]] +name = "black" +version = "23.3.0" + +[[package]] +name = "flake8" +version = "6.0.0" + +[[package]] +name = "requests" +version = "2.31.0" + +[[package]] +name = "click" +version = "8.1.0" +EOF + +ACTUAL=$(python3 "$SCRIPT" "$TMPDIR/old.lock" "$TMPDIR/new.lock") + +check "changed version" \ + "- black: \`23.1.0\` → \`23.3.0\`" \ + "$(echo "$ACTUAL" | grep '^- black:')" + +check "added package" \ + "- click: added \`8.1.0\`" \ + "$(echo "$ACTUAL" | grep '^- click:')" + +check "unchanged package omitted" \ + "" \ + "$(echo "$ACTUAL" | grep '^- flake8:' || true)" + +check "line count" \ + "2" \ + "$(echo "$ACTUAL" | grep -c '^-')" + +cat > "$TMPDIR/old2.lock" <<'EOF' +[[package]] +name = "urllib3" +version = "2.0.0" +EOF + +cat > "$TMPDIR/new2.lock" <<'EOF' +EOF + +ACTUAL2=$(python3 "$SCRIPT" "$TMPDIR/old2.lock" "$TMPDIR/new2.lock") +check "removed package" \ + "- urllib3: removed \`2.0.0\`" \ + "$ACTUAL2" + +cat > "$TMPDIR/same.lock" <<'EOF' +[[package]] +name = "idna" +version = "3.4" +EOF + +ACTUAL3=$(python3 "$SCRIPT" "$TMPDIR/same.lock" "$TMPDIR/same.lock") +check "no changes -> empty output" "" "$ACTUAL3" + +exit $FAIL diff --git a/actions/pre-commit-autoupdate/action.yml b/actions/pre-commit-autoupdate/action.yml index 1b59c4b..b80fa36 100644 --- a/actions/pre-commit-autoupdate/action.yml +++ b/actions/pre-commit-autoupdate/action.yml @@ -37,6 +37,8 @@ runs: with: app-id: ${{ inputs.app-id }} private-key: ${{ inputs.app-private-key }} + permission-contents: write + permission-pull-requests: write - name: Install prek shell: bash