From 026b482960d01eea5998be239d2d017f66da9f13 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 2 Aug 2026 10:54:57 -0500 Subject: [PATCH 1/6] feat: add poetry.lock diff script for lock-update action --- actions/poetry-lock-update/diff_lock.py | 44 +++++++++ actions/poetry-lock-update/test_diff_lock.sh | 96 ++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 actions/poetry-lock-update/diff_lock.py create mode 100755 actions/poetry-lock-update/test_diff_lock.sh 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 From 0803d3579d33a836f058231c078cd93f1479f28c Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 2 Aug 2026 11:11:54 -0500 Subject: [PATCH 2/6] feat: add poetry-lock-update composite action --- actions/poetry-lock-update/action.yml | 95 +++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 actions/poetry-lock-update/action.yml diff --git a/actions/poetry-lock-update/action.yml b/actions/poetry-lock-update/action.yml new file mode 100644 index 0000000..36744bf --- /dev/null +++ b/actions/poetry-lock-update/action.yml @@ -0,0 +1,95 @@ +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 }} + + - 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}" + + 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 From 46ed7d1b248535ed0e90b48a17a3ca2ac67f6762 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 2 Aug 2026 13:28:53 -0500 Subject: [PATCH 3/6] test: wire poetry-lock-update into CI --- .github/workflows/tests.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 From 96d74cc8119fb8775ac9b6f554cab2c712ec41da Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 2 Aug 2026 14:06:12 -0500 Subject: [PATCH 4/6] docs: document poetry-lock-update action --- .gitignore | 3 +++ CLAUDE.md | 46 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 CLAUDE.md 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..0cb6fe9 --- /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 blink1073:`. 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`. From 5c902fdc9aa477ecc586b579e0fb836148e48ffa Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 2 Aug 2026 16:42:35 -0500 Subject: [PATCH 5/6] docs: generalize PR guidance instead of hardcoding a specific fork --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0cb6fe9..21be432 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,4 +43,4 @@ Each action lives in `actions//action.yml`. The actions are: ### PRs -Always target the upstream repo: `--repo Calysto/maintainer_tools --head blink1073:`. +Always target the upstream repo: `--repo Calysto/maintainer_tools --head :`. From a7116da62e65a50f1fd01dff5fda303c97ce0209 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 2 Aug 2026 16:48:31 -0500 Subject: [PATCH 6/6] fix: scope GitHub App token permissions; support Python <3.11 in diff_lock.py invocation --- actions/poetry-lock-update/action.yml | 6 ++++++ actions/pre-commit-autoupdate/action.yml | 2 ++ 2 files changed, 8 insertions(+) diff --git a/actions/poetry-lock-update/action.yml b/actions/poetry-lock-update/action.yml index 36744bf..27788a9 100644 --- a/actions/poetry-lock-update/action.yml +++ b/actions/poetry-lock-update/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: Save current lock file shell: bash @@ -66,6 +68,10 @@ runs: 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="" 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