Skip to content
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
16 changes: 16 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 46 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<name>@v1
```

### Actions

Each action lives in `actions/<name>/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 <your-github-username>:<branch>`.
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
101 changes: 101 additions & 0 deletions actions/poetry-lock-update/action.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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
44 changes: 44 additions & 0 deletions actions/poetry-lock-update/diff_lock.py
Original file line number Diff line number Diff line change
@@ -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 <old.lock> <new.lock>", 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()
96 changes: 96 additions & 0 deletions actions/poetry-lock-update/test_diff_lock.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading