diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml
index 0863d4a..36f910b 100644
--- a/.github/workflows/auto-update-precommit-hooks.yml
+++ b/.github/workflows/auto-update-precommit-hooks.yml
@@ -63,15 +63,14 @@ concurrency:
group: auto-update-precommit-hooks
cancel-in-progress: false
-# Deny all permissions by default; grant only what's needed per job
-permissions: {}
+permissions: {} # Deny token access by default; jobs grant only the permissions they need.
jobs:
detect-updates:
name: Detect Available Updates
runs-on: ubuntu-latest
- permissions:
- contents: read
+ permissions: # Each job receives only the repository access required for its stage.
+ contents: read # Required to inspect the pre-commit configuration and tracking data.
outputs:
updates_found: ${{ steps.detect.outputs.updates_found }}
updates_json: ${{ steps.detect.outputs.updates_json }}
@@ -81,226 +80,26 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
-
- name: Set up Python
- uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
- python-version: "3.11"
-
- - name: Install dependencies
- run: |
- pip install pyyaml
-
+ python-version: "3.13"
+ - name: Install package
+ run: python -m pip install .
+ - name: Validate pre-commit tracking alignment
+ run: python -m precommit_updates validate
- name: Detect available updates
id: detect
env:
GH_TOKEN: ${{ github.token }}
- run: |
- python3 << 'EOF'
- import yaml
- import json
- import subprocess
- import os
- import re
- from typing import Optional, Tuple, Dict, Any
-
- def get_latest_release(repo_url: str) -> Optional[Tuple[str, str]]:
- """Fetch latest release tag and resolve to immutable commit SHA from GitHub API."""
- try:
- # Extract owner/repo from URL
- match = re.search(r'github\.com/([^/]+)/(.+?)(?:\.git)?$', repo_url)
- if not match:
- print(f"Warning: Could not parse repo URL: {repo_url}")
- return None
-
- owner, repo = match.groups()
-
- # Get the latest release tag (immutable)
- cmd = [
- 'gh', 'api', '--paginate',
- f'repos/{owner}/{repo}/releases',
- '-q', '.[0].tag_name'
- ]
-
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
- if result.returncode == 0 and result.stdout.strip():
- tag = result.stdout.strip()
-
- # Resolve the tag to its immutable commit SHA through the commits API
- commit_cmd = [
- 'gh', 'api', '--paginate',
- f'repos/{owner}/{repo}/commits/{tag}',
- '-q', '.sha'
- ]
-
- commit_result = subprocess.run(commit_cmd, capture_output=True, text=True, timeout=10)
- if commit_result.returncode == 0 and commit_result.stdout.strip():
- sha = commit_result.stdout.strip()
- return (tag, sha)
- except Exception as e:
- print(f"Warning: Error fetching latest release for {repo_url}: {e}")
-
- return None
-
- def get_latest_commit_sha(repo_url: str, branch: str = 'HEAD') -> Optional[str]:
- """Fetch latest commit SHA from a repository."""
- try:
- match = re.search(r'github\.com/([^/]+)/(.+?)(?:\.git)?$', repo_url)
- if not match:
- return None
-
- owner, repo = match.groups()
-
- cmd = [
- 'gh', 'api', '--paginate',
- f'repos/{owner}/{repo}/commits',
- '-q', '.[0].sha'
- ]
-
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
- if result.returncode == 0 and result.stdout.strip():
- return result.stdout.strip()
- except Exception as e:
- print(f"Warning: Error fetching latest commit for {repo_url}: {e}")
-
- return None
-
- def resolve_sha_to_tag(repo_url: str, sha: str) -> Optional[str]:
- """Resolve a commit SHA to its tag, if one exists."""
- try:
- match = re.search(r'github\.com/([^/]+)/(.+?)(?:\.git)?$', repo_url)
- if not match:
- return None
-
- owner, repo = match.groups()
-
- # Try to get tags that point to this SHA
- cmd = [
- 'gh', 'api', '--paginate',
- f'repos/{owner}/{repo}/tags',
- '-q', '.[] | select(.commit.sha == "{sha}") | .name'
- ]
-
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
- if result.returncode == 0 and result.stdout.strip():
- # Return the first matching tag
- tags = result.stdout.strip().split('\n')
- if tags and tags[0]:
- return tags[0]
- except Exception as e:
- print(f"Warning: Could not resolve SHA to tag: {e}")
-
- return None
-
- def parse_version(tag: str) -> Optional[Tuple[int, int, int]]:
- """Parse semantic version from tag (e.g., v1.2.3 -> (1, 2, 3))."""
- match = re.match(r'v?(\d+)\.(\d+)\.(\d+)', tag)
- if match:
- return tuple(map(int, match.groups()))
- return None
-
- def determine_semver_level(old_version: str, new_version: str) -> str:
- """Determine if update is major, minor, or patch."""
- old = parse_version(old_version)
- new = parse_version(new_version)
-
- if not old or not new:
- return "unknown"
-
- if old[0] != new[0]:
- return "major"
- elif old[1] != new[1]:
- return "minor"
- else:
- return "patch"
-
- # Load current pre-commit config
- with open('.pre-commit-config.yaml', 'r') as f:
- config = yaml.safe_load(f)
-
- updates = []
- # Load tracking data to get current version information
- tracking_data = {}
- try:
- with open('configs/precommit-update-tracking.json', 'r') as f:
- tracking_data = json.load(f).get('hooks', {})
- except FileNotFoundError:
- pass
-
- for repo_entry in config.get('repos', []):
- repo_url = repo_entry.get('repo')
- current_sha = repo_entry.get('rev')
-
- if not repo_url or not current_sha:
- continue
-
- print(f"Checking updates for: {repo_url}")
-
- # Try to get latest release first
- release_info = get_latest_release(repo_url)
-
- if release_info:
- latest_tag, latest_sha = release_info
- if latest_sha != current_sha:
- # Determine old version from tracking file or by resolving the SHA
- old_version = None
-
- # First, try to get from tracking file
- if repo_url in tracking_data:
- old_version = tracking_data[repo_url].get('current_version')
-
- # If not in tracking, try to resolve the SHA to its tag
- if not old_version:
- old_version = resolve_sha_to_tag(repo_url, current_sha)
-
- # If still no version, use the SHA (fallback for untagged repos)
- if not old_version:
- old_version = current_sha[:7]
-
- updates.append({
- 'repo': repo_url,
- 'old_sha': current_sha,
- 'new_sha': latest_sha,
- 'old_version': old_version,
- 'new_version': latest_tag,
- 'semver_level': determine_semver_level(old_version or '0.0.0', latest_tag),
- 'commit_range': f'{current_sha}...{latest_sha}'
- })
- print(f" Update available: {old_version} -> {latest_tag}")
- else:
- # Fall back to latest commit if no releases
- latest_sha = get_latest_commit_sha(repo_url)
- if latest_sha and latest_sha != current_sha:
- print(f" Update available (commit): {current_sha[:7]} -> {latest_sha[:7]}")
- updates.append({
- 'repo': repo_url,
- 'old_sha': current_sha,
- 'new_sha': latest_sha,
- 'old_version': current_sha[:7],
- 'new_version': latest_sha[:7],
- 'semver_level': 'patch', # Default to patch for commits
- 'commit_range': f'{current_sha}...{latest_sha}'
- })
-
- # Output results
- if updates:
- with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
- f.write(f"updates_found=true\n")
- f.write(f"updates_json={json.dumps(updates)}\n")
- print(f"\nFound {len(updates)} update(s)")
- else:
- with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
- f.write(f"updates_found=false\n")
- f.write(f"updates_json={json.dumps([])}\n")
- print("No updates found")
- EOF
+ run: python -m precommit_updates detect
apply-cooldown:
name: Apply Cooldown Filters
needs: detect-updates
runs-on: ubuntu-latest
- permissions:
- contents: read
+ permissions: # Each job receives only the repository access required for its stage.
+ contents: read # Required to read tracking data while applying cooldown policy.
outputs:
eligible_updates: ${{ steps.cooldown.outputs.eligible_updates }}
skipped_updates: ${{ steps.cooldown.outputs.skipped_updates }}
@@ -310,16 +109,12 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
-
- name: Set up Python
- uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
- python-version: "3.11"
-
- - name: Install dependencies
- run: |
- pip install pyyaml
-
+ python-version: "3.13"
+ - name: Install package
+ run: python -m pip install .
- name: Apply cooldown filters
id: cooldown
env:
@@ -329,88 +124,14 @@ jobs:
COOLDOWN_MAJOR: ${{ github.event.inputs.cooldown_major_days || '28' }}
COOLDOWN_MINOR: ${{ github.event.inputs.cooldown_minor_days || '14' }}
COOLDOWN_PATCH: ${{ github.event.inputs.cooldown_patch_days || '7' }}
- run: |
- python3 << 'EOF'
- import json
- import os
- from datetime import datetime, timedelta, timezone
-
- # Load updates
- updates = json.loads(os.environ['UPDATES_JSON'])
- force_update = os.environ['FORCE_UPDATE'].lower() == 'true'
- skip_hooks = [h.strip() for h in os.environ['SKIP_HOOKS'].split(',') if h.strip()]
-
- # Load cooldown config
- try:
- with open('configs/precommit-update-tracking.json', 'r') as f:
- tracking = json.load(f)
- except FileNotFoundError:
- tracking = {'hooks': {}}
-
- # Parse cooldown periods
- cooldown_days = {
- 'major': int(os.environ['COOLDOWN_MAJOR']),
- 'minor': int(os.environ['COOLDOWN_MINOR']),
- 'patch': int(os.environ['COOLDOWN_PATCH'])
- }
-
- now = datetime.now(timezone.utc)
- eligible = []
- skipped = []
-
- for update in updates:
- repo = update['repo']
- semver_level = update['semver_level']
-
- # Check if hook is in skip list
- if repo in skip_hooks:
- skipped.append({**update, 'reason': 'Hook in skip list'})
- continue
-
- # If force_update is true, always include
- if force_update:
- update['cooldown_applied'] = cooldown_days
- eligible.append(update)
- continue
-
- # Check cooldown
- hook_info = tracking.get('hooks', {}).get(repo, {})
- last_updated = hook_info.get('semver_levels', {}).get(semver_level)
-
- if last_updated:
- last_updated_dt = datetime.fromisoformat(last_updated.replace('Z', '+00:00'))
- cooldown_days_for_level = cooldown_days[semver_level]
- days_elapsed = (now - last_updated_dt).days
-
- if days_elapsed < cooldown_days_for_level:
- skipped.append({
- **update,
- 'reason': f'Cooldown active: {days_elapsed}/{cooldown_days_for_level} days',
- 'days_remaining': cooldown_days_for_level - days_elapsed
- })
- continue
-
- # Passed all checks
- update['cooldown_applied'] = cooldown_days
- eligible.append(update)
-
- # Output
- with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
- f.write(f"eligible_updates={json.dumps(eligible)}\n")
- f.write(f"skipped_updates={json.dumps(skipped)}\n")
-
- if eligible:
- print(f"✓ {len(eligible)} update(s) eligible after cooldown filter")
- if skipped:
- print(f"⊘ {len(skipped)} update(s) skipped by cooldown filter")
- EOF
+ run: python -m precommit_updates cooldown
fetch-release-info:
name: Fetch Release Information
needs: apply-cooldown
runs-on: ubuntu-latest
- permissions:
- contents: read
+ permissions: # Each job receives only the repository access required for its stage.
+ contents: read # Required to read repository metadata for release enrichment.
outputs:
release_info: ${{ steps.release.outputs.release_info }}
if: needs.apply-cooldown.outputs.eligible_updates != '[]' && needs.apply-cooldown.outputs.eligible_updates != ''
@@ -419,117 +140,26 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
-
- name: Set up Python
- uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
- python-version: "3.11"
-
+ python-version: "3.13"
+ - name: Install package
+ run: python -m pip install .
- name: Fetch release information
id: release
env:
ELIGIBLE_JSON: ${{ needs.apply-cooldown.outputs.eligible_updates }}
GH_TOKEN: ${{ github.token }}
- run: |
- python3 << 'EOF'
- import json
- import os
- import subprocess
- import re
- from typing import Optional, Dict, Any
-
- def get_release_notes(repo_url: str, tag: str) -> Optional[str]:
- """Fetch release notes from GitHub API."""
- try:
- match = re.search(r'github\.com/([^/]+)/(.+?)(?:\.git)?$', repo_url)
- if not match:
- return None
-
- owner, repo = match.groups()
-
- # Get release by tag
- cmd = [
- 'gh', 'api',
- f'repos/{owner}/{repo}/releases/tags/{tag}',
- '-q', '.body'
- ]
-
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
- if result.returncode == 0:
- return result.stdout.strip() or "(No release notes)"
- except Exception as e:
- print(f"Warning: Could not fetch release notes: {e}")
-
- return None
-
- def get_commit_messages(repo_url: str, old_sha: str, new_sha: str) -> list:
- """Fetch commit messages between two SHAs."""
- try:
- match = re.search(r'github\.com/([^/]+)/(.+?)(?:\.git)?$', repo_url)
- if not match:
- return []
-
- owner, repo = match.groups()
-
- # Get commits in range
- cmd = [
- 'gh', 'api', '--paginate',
- f'repos/{owner}/{repo}/commits',
- f'-q', '.[].sha'
- ]
-
- # Note: This is a simplified approach; ideally we'd use the commit comparison API
- # For MVP, we'll just fetch recent commits
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
- if result.returncode == 0:
- commits = result.stdout.strip().split('\n')
- return commits[:10] # Return first 10 commits
- except Exception as e:
- print(f"Warning: Could not fetch commits: {e}")
-
- return []
-
- # Load eligible updates
- updates = json.loads(os.environ['ELIGIBLE_JSON'])
-
- enriched_updates = []
-
- for update in updates:
- repo_url = update['repo']
- new_version = update['new_version']
- old_sha = update['old_sha']
- new_sha = update['new_sha']
-
- # Fetch release notes
- release_notes = None
- if not new_version.startswith('451b56af716f') and not new_version.startswith('3db014c16a9d'): # Not a SHA
- release_notes = get_release_notes(repo_url, new_version)
-
- # Fetch commit messages
- commits = get_commit_messages(repo_url, old_sha, new_sha)
-
- enriched = {
- **update,
- 'release_notes': release_notes or "(No release notes available)",
- 'commits': commits,
- 'commit_count': len(commits)
- }
- enriched_updates.append(enriched)
-
- # Output
- with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
- f.write(f"release_info={json.dumps(enriched_updates)}\n")
-
- print(f"Enriched {len(enriched_updates)} update(s) with release information")
- EOF
+ run: python -m precommit_updates release-info
update-config-and-create-pr:
name: Update Config and Create PR
needs: [detect-updates, apply-cooldown, fetch-release-info]
runs-on: ubuntu-latest
- permissions:
- contents: write # needed to commit and push updated configs
- pull-requests: write # needed to create PR for updates
+ permissions: # The final job needs write access to publish the update branch and pull request.
+ contents: write # Required to commit and push updated pre-commit configuration.
+ pull-requests: write # Required to create the update pull request.
if: needs.apply-cooldown.outputs.eligible_updates != '[]' && needs.apply-cooldown.outputs.eligible_updates != ''
steps:
- name: Check out repository
@@ -537,251 +167,39 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
-
- name: Set up Python
- uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
- python-version: "3.11"
-
- - name: Install dependencies
- run: |
- pip install pyyaml
-
+ python-version: "3.13"
+ - name: Install package
+ run: python -m pip install .
+ - name: Initialize pre-commit tracking if needed
+ run: python -m precommit_updates validate
- name: Update configs and create PR
env:
RELEASE_INFO: ${{ needs.fetch-release-info.outputs.release_info }}
SKIPPED_UPDATES: ${{ needs.apply-cooldown.outputs.skipped_updates }}
GITHUB_TOKEN: ${{ github.token }}
+ FORCE_UPDATE: ${{ github.event.inputs.force_update || false }}
COOLDOWN_MAJOR: ${{ github.event.inputs.cooldown_major_days || '28' }}
COOLDOWN_MINOR: ${{ github.event.inputs.cooldown_minor_days || '14' }}
COOLDOWN_PATCH: ${{ github.event.inputs.cooldown_patch_days || '7' }}
- run: |
- python3 << 'EOF'
- import json
- import os
- import yaml
- import subprocess
- import re
- from datetime import datetime, timezone
-
- # Load release info
- release_info = json.loads(os.environ['RELEASE_INFO'])
- skipped = json.loads(os.environ['SKIPPED_UPDATES'] or '[]')
-
- cooldown_config = {
- 'major': int(os.environ['COOLDOWN_MAJOR']),
- 'minor': int(os.environ['COOLDOWN_MINOR']),
- 'patch': int(os.environ['COOLDOWN_PATCH'])
- }
-
- # Check if there are actual updates to apply
- if not release_info:
- print("No updates to apply")
- exit(0)
-
- # Load and update .pre-commit-config.yaml
- with open('.pre-commit-config.yaml', 'r') as f:
- config = yaml.safe_load(f)
-
- # Update tracking file
- try:
- with open('configs/precommit-update-tracking.json', 'r') as f:
- tracking = json.load(f)
- except FileNotFoundError:
- tracking = {'last_updated': datetime.utcnow().isoformat() + 'Z', 'hooks': {}}
-
- # Create a mapping of repo URLs to new SHAs
- update_map = {update['repo']: update for update in release_info}
-
- # Update config
- for repo_entry in config.get('repos', []):
- repo_url = repo_entry.get('repo')
- if repo_url in update_map:
- update = update_map[repo_url]
- repo_entry['rev'] = update['new_sha']
-
- # Update tracking
- if repo_url not in tracking['hooks']:
- tracking['hooks'][repo_url] = {
- 'current_sha': update['new_sha'],
- 'current_version': update['new_version'],
- 'semver_levels': {
- 'major': datetime.utcnow().isoformat() + 'Z',
- 'minor': datetime.utcnow().isoformat() + 'Z',
- 'patch': datetime.utcnow().isoformat() + 'Z'
- }
- }
- else:
- semver = update['semver_level']
- tracking['hooks'][repo_url]['current_sha'] = update['new_sha']
- tracking['hooks'][repo_url]['current_version'] = update['new_version']
- if semver != 'unknown':
- tracking['hooks'][repo_url]['semver_levels'][semver] = datetime.utcnow().isoformat() + 'Z'
-
- tracking['hooks'][repo_url]['last_updated'] = datetime.utcnow().isoformat() + 'Z'
-
- # Write updated files
- with open('.pre-commit-config.yaml', 'w') as f:
- yaml.dump(config, f, default_flow_style=False, sort_keys=False)
-
- with open('configs/precommit-update-tracking.json', 'w') as f:
- json.dump(tracking, f, indent=2)
-
- # Define helper functions for PR generation
- def extract_hook_name(repo_url: str) -> str:
- """Extract hook name from repository URL."""
- match = re.search(r'/([^/]+?)(?:\.git)?$', repo_url)
- if match:
- return match.group(1)
- return repo_url
-
- def generate_pr_body(updates: list, skipped: list, cooldown_config: dict) -> str:
- """Generate comprehensive PR body."""
- now = datetime.now(timezone.utc)
-
- lines = []
- lines.append("## Summary")
- lines.append("")
- actor = os.environ.get('GITHUB_ACTOR', 'Workflow')
- date_str = now.strftime('%Y-%m-%d %H:%M:%S UTC')
- lines.append(f"{actor} ran on {date_str} and updated **{len(updates)} hook(s)**.")
- lines.append("")
-
- run_id = os.environ['GITHUB_RUN_ID']
- server_url = os.environ.get('GITHUB_SERVER_URL', 'https://github.com')
- repo = os.environ.get('GITHUB_REPOSITORY', 'unknown')
- lines.append(f"**Workflow run**: [{run_id}]({server_url}/{repo}/actions/runs/{run_id})")
- lines.append("")
- lines.append("---")
- lines.append("")
- lines.append("## Changes")
- lines.append("")
-
- # Add each update
- for update in updates:
- hook_name = extract_hook_name(update['repo'])
- old_version = update['old_version']
- new_version = update['new_version']
- semver_level = update['semver_level'].upper()
-
- lines.append(f"### {hook_name} `[{semver_level}]`")
- lines.append(f"`{old_version}` → `{new_version}`")
- lines.append("")
-
- # Commits
- if update.get('commits'):
- lines.append(f"Commits ({update['commit_count']})
")
- lines.append("")
- lines.append("```")
- for commit in update['commits'][:10]:
- lines.append(f"- {commit[:7]}")
- if update['commit_count'] > 10:
- lines.append(f"... and {update['commit_count'] - 10} more")
- lines.append("```")
- lines.append("")
- lines.append(f"[View commit history]({update['repo']}/compare/{update['old_sha'][:7]}...{update['new_sha'][:7]})")
- lines.append("")
- lines.append(" ")
- lines.append("")
-
- # Release notes
- release_notes = update.get('release_notes', '(No release notes)')
- if release_notes and release_notes != '(No release notes available)':
- lines.append(f"Release Notes
")
- lines.append("")
- lines.append(release_notes)
- lines.append("")
- lines.append(" ")
- lines.append("")
-
- # Risks/Notes section
- lines.append("---")
- lines.append("")
- lines.append("## Risks & Notes")
- lines.append("")
-
- # Check for major updates
- has_major = any(u['semver_level'] == 'major' for u in updates)
- if has_major:
- lines.append("> [!WARNING]")
- lines.append("> Major Version Updates")
- lines.append(">")
- lines.append("> Major versions may introduce breaking changes. Reviewers should examine the release notes and commit history carefully.")
- lines.append("")
-
- # Check for short cooldown
- has_short_cooldown = any(u['cooldown_applied'].get(u['semver_level'], 0) < 7 for u in updates)
- if has_short_cooldown:
- lines.append("> [!WARNING]")
- lines.append("> Short Cooldown Period")
- lines.append(">")
- lines.append("> Some updates have cooldown periods less than 7 days, which may increase vulnerability to supply chain attacks. Longer cooldown periods provide greater stability and more time to detect potential supply chain issues.")
- lines.append("")
-
- # Cooldown summary
- lines.append("### Cooldown Periods Applied")
- lines.append("")
- lines.append(f"- **Major versions**: {cooldown_config['major']} days")
- lines.append(f"- **Minor versions**: {cooldown_config['minor']} days")
- lines.append(f"- **Patch versions**: {cooldown_config['patch']} days")
- lines.append("")
-
- # Skipped updates
- if skipped:
- lines.append("### Skipped Updates")
- lines.append("")
- lines.append("The following updates are available but skipped due to active cooldown periods:")
- lines.append("")
- for update in skipped:
- hook_name = extract_hook_name(update['repo'])
- reason = update.get('reason', 'Cooldown active')
- lines.append(f"- **{hook_name}**: {reason}")
-
- return "\n".join(lines)
-
- # Generate PR body
- pr_body = generate_pr_body(release_info, skipped, cooldown_config)
-
- # Create branch and commit
- branch_name = f"chore/precommit-updates-{datetime.utcnow().strftime('%Y%m%d')}"
-
- subprocess.run(['git', 'config', 'user.name', 'github-actions[bot]'], check=True)
- subprocess.run(['git', 'config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'], check=True)
- subprocess.run(['git', 'checkout', '-b', branch_name], check=True)
- subprocess.run(['git', 'add', '.pre-commit-config.yaml', 'configs/precommit-update-tracking.json'], check=True)
-
- commit_msg = f"chore(pre-commit): auto-update hooks\n\nUpdated {len(release_info)} pre-commit hook(s)"
- subprocess.run(['git', 'commit', '-m', commit_msg], check=True)
-
- # Push branch
- subprocess.run(['git', 'push', '-u', 'origin', branch_name], check=True, env={**os.environ, 'GIT_TRACE': '1'})
-
- # Create PR using GitHub CLI
- pr_result = subprocess.run(
- [
- 'gh', 'pr', 'create',
- '--base', 'main',
- '--head', branch_name,
- '--title', f'chore(pre-commit): auto-update hooks ({len(release_info)} update(s))',
- '--body', pr_body,
- ],
- capture_output=True,
- text=True
- )
-
- if pr_result.returncode == 0:
- print(f"✓ Pull request created: {pr_result.stdout.strip()}")
- else:
- print(f"Error creating PR: {pr_result.stderr}")
- exit(1)
- EOF
+ run: python -m precommit_updates apply
no-updates:
name: No Updates Available
needs: detect-updates
runs-on: ubuntu-latest
- permissions: {}
+ permissions: {} # This reporting job does not access the GitHub token.
if: needs.detect-updates.outputs.updates_found == 'false'
steps:
- - name: Log
- run: echo "No pre-commit hook updates available at this time."
+ - name: Report no updates
+ run: |
+ echo '::notice title=Workflow complete::No pre-commit hook updates are available.'
+ {
+ echo '## Workflow complete'
+ echo
+ echo '> **No updates available**'
+ echo
+ echo 'No pull request was created.'
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d646835
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.pyc
+__pycache__/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index eb454fb..e841778 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,11 +1,11 @@
repos:
- - repo: https://github.com/zizmorcore/zizmor-pre-commit
- rev: 451b56af716f9f0d0c2b816503a3fd0cf8b036fa # frozen: v1.29.0
- hooks:
- - id: zizmor
- args: [--fix, --persona=pedantic]
- - repo: https://github.com/compilerla/conventional-pre-commit
- rev: 3db014c16a9d31997ab8c07a4d61fcce936c8f0d # frozen: v4.4.0
- hooks:
- - id: conventional-pre-commit
- stages: [commit-msg]
\ No newline at end of file
+- repo: https://github.com/zizmorcore/zizmor-pre-commit
+ rev: fa412071e4f5d44d44f9e365f4676f9df92456a2 # frozen: v1.30.1
+ hooks:
+ - id: zizmor
+ args: [--fix, --persona=pedantic]
+- repo: https://github.com/compilerla/conventional-pre-commit
+ rev: 91ab4bf57e58b32adf1a122681f6ebe164d081c8 # frozen: v4.4.0
+ hooks:
+ - id: conventional-pre-commit
+ stages: [commit-msg]
diff --git a/configs/checkov.yml b/configs/checkov.yml
index 9e26dfe..73b734b 100644
--- a/configs/checkov.yml
+++ b/configs/checkov.yml
@@ -1 +1,3 @@
-{}
\ No newline at end of file
+skip-check:
+ # This workflow intentionally exposes workflow_dispatch controls for update policy.
+ - CKV_GHA_7
diff --git a/configs/precommit-update-tracking.json b/configs/precommit-update-tracking.json
index 6cb4bfd..0fedb5c 100644
--- a/configs/precommit-update-tracking.json
+++ b/configs/precommit-update-tracking.json
@@ -1,25 +1,21 @@
{
- "last_updated": "2026-09-10T00:00:00Z",
+ "last_updated": "2026-09-18T09:06:17.752603+00:00",
"hooks": {
"https://github.com/zizmorcore/zizmor-pre-commit": {
- "last_updated": "2026-09-10T00:00:00Z",
- "current_sha": "451b56af716f9f0d0c2b816503a3fd0cf8b036fa",
- "current_version": "v1.29.0",
+ "current_sha": "fa412071e4f5d44d44f9e365f4676f9df92456a2",
"semver_levels": {
- "major": "2026-09-10T00:00:00Z",
- "minor": "2026-09-10T00:00:00Z",
- "patch": "2026-09-10T00:00:00Z"
- }
+ "minor": "2026-09-18T09:06:17.741932+00:00"
+ },
+ "current_version": "v1.30.1",
+ "last_updated": "2026-09-18T09:06:17.741932+00:00"
},
"https://github.com/compilerla/conventional-pre-commit": {
- "last_updated": "2026-09-10T00:00:00Z",
- "current_sha": "3db014c16a9d31997ab8c07a4d61fcce936c8f0d",
- "current_version": "v4.4.0",
+ "current_sha": "91ab4bf57e58b32adf1a122681f6ebe164d081c8",
"semver_levels": {
- "major": "2026-09-10T00:00:00Z",
- "minor": "2026-09-10T00:00:00Z",
- "patch": "2026-09-10T00:00:00Z"
- }
+ "minor": "2026-09-18T09:06:17.752603+00:00"
+ },
+ "current_version": "v4.4.0",
+ "last_updated": "2026-09-18T09:06:17.752603+00:00"
}
}
}
diff --git a/docs/explanation/ADR-0006-stateful-precommit-update-automation.md b/docs/explanation/ADR-0006-stateful-precommit-update-automation.md
new file mode 100644
index 0000000..628983b
--- /dev/null
+++ b/docs/explanation/ADR-0006-stateful-precommit-update-automation.md
@@ -0,0 +1,82 @@
+# ADR-0006: Stateful pre-commit update automation
+
+- Status: Accepted
+- Date: 2026-09-17
+
+## Context
+
+We needed to automate updates to the commit-pinned pre-commit hooks used by this repository without turning upstream release activity into an automatic change to the default branch. The workflow must discover new releases, provide enough release context for review, remember update history, and create a pull request for a human to approve.
+
+This introduces an architectural trust boundary: most workflow stages only inspect repository contents and public upstream metadata, while one final stage can write repository contents and create pull requests. It also introduces state because cooldown decisions cannot be made reliably from the current `.pre-commit-config.yaml` alone.
+
+## Decision
+
+Implement the automation as a stateful, staged workflow in `auto-update-precommit-hooks.yml`:
+
+1. **Detect updates** from tagged upstream releases and resolve each release tag to an immutable commit SHA. Only hooks with tagged releases are considered.
+2. **Apply policy** using semver-level cooldowns, the committed tracking file, the configured skip list, and an explicit manual `force_update` override.
+3. **Fetch release context** such as release notes and commit information for updates that passed policy.
+4. **Write and propose changes** in a single job that updates `.pre-commit-config.yaml` and `configs/precommit-update-tracking.json`, pushes a bot branch, and creates a pull request. The default branch is changed only through the normal pull request review and merge process.
+
+The workflow uses `contents: read` for detection, filtering, and release-context jobs. Only the final job receives `contents: write` and `pull-requests: write`. Actions use pinned commit SHAs, checkout does not persist credentials, and the GitHub token is passed only to the steps that need it.
+
+## Rationale
+
+1. **Release trust and reproducibility**
+ Release tags are used as the human-readable update signal, but the configuration is updated to the resolved commit SHA. This preserves reviewable release information while preventing a mutable tag from changing the code consumed by pre-commit.
+
+2. **Cooldowns reduce supply-chain exposure**
+ A newly published release is not adopted immediately by default. Major updates wait 28 days, minor updates 14 days, and patch updates 7 days. The graduated periods reflect increasing compatibility risk while allowing security and bug-fix updates to move sooner. The waiting period also provides time for upstream issues or malicious releases to become visible.
+
+3. **Tracking state makes policy durable**
+ `configs/precommit-update-tracking.json` records the current SHA, current version, last update, and last update time for each semver level. Committing this state in the same pull request as the hook change makes cooldown decisions reproducible across scheduled runs and auditable in Git history.
+
+4. **Pull requests preserve human control**
+ The workflow creates a pull request containing release notes, commit information, cooldown policy, and risk warnings. It does not merge the change. Reviewers remain responsible for deciding whether an upstream release is suitable for the repository.
+
+5. **Force updates remain explicit**
+ `force_update` is available only as an explicit workflow input and is documented as a supply-chain risk. Keeping the normal path subject to cooldowns makes the secure behavior the default while preserving an operational escape hatch for urgent fixes.
+
+6. **Write access is isolated**
+ Separating read-only discovery from the write-enabled PR job limits the impact of failures or compromised data in upstream metadata. The write boundary is easy to audit and is reached only after the update has passed filtering and release-context collection.
+
+## Consequences
+
+Positive:
+
+- Hook updates are commit-pinned, reviewable, and traceable to upstream releases.
+- Scheduled runs can make consistent cooldown decisions using committed state.
+- The default branch is protected by the existing pull request review process.
+- Read-only jobs do not need write-capable credentials.
+- Release notes and risk information are available to reviewers in the generated pull request.
+
+Negative:
+
+- The tracking file is additional repository state that must remain consistent with `.pre-commit-config.yaml`.
+- Cooldowns delay adoption of some fixes and require an explicit override for urgent updates.
+- The workflow is more complex than a direct `pre-commit autoupdate` job because it must resolve releases, preserve state, and construct a reviewable pull request.
+- The generated pull request still requires human review and merge, so automation cannot guarantee that hooks are always current.
+
+## Alternatives considered
+
+1. **Update the default branch directly**
+ - Pro: No pull request queue or manual merge step
+ - Con: Removes the review gate for third-party code and would give scheduled automation direct write authority over the default branch
+
+2. **Use mutable release tags in `.pre-commit-config.yaml`**
+ - Pro: Simpler configuration and readable diffs
+ - Con: A tag can be retargeted after review, so the consumed hook would not be reproducible
+
+3. **Use only the current configuration as state**
+ - Pro: No tracking file to maintain
+ - Con: There is no durable record of when each semver level was last adopted, making cooldown enforcement unreliable across runs
+
+4. **Adopt every available release immediately**
+ - Pro: Fastest access to upstream fixes
+ - Con: Increases exposure to compromised or defective releases before they have had time to receive scrutiny
+
+## Related decisions
+
+- ADR-0001: Called workflow owns secret usage
+- ADR-0002: Use workflow_dispatch instead of repository_dispatch
+- ADR-0004: Separate reusable workflow pinning from dispatch ref
\ No newline at end of file
diff --git a/docs/explanation/README.md b/docs/explanation/README.md
index 6855d85..81b385a 100644
--- a/docs/explanation/README.md
+++ b/docs/explanation/README.md
@@ -9,3 +9,4 @@ Background, rationale, and trade-offs.
- [ADR-0003: Unified project_field_values input with optional field updates](ADR-0003-unified-project-field-values-input.md)
- [ADR-0004: Separate reusable workflow pinning from dispatch ref](ADR-0004-separate-reusable-pinning-from-dispatch-ref.md)
- [ADR-0005: Security workflow orchestration pattern](ADR-0005-security-workflow-orchestration.md)
+- [ADR-0006: Stateful pre-commit update automation](ADR-0006-stateful-precommit-update-automation.md)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..7b538eb
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "precommit-updates"
+version = "0.1.0"
+description = "Testable automation for commit-pinned pre-commit hook updates"
+requires-python = ">=3.11"
+dependencies = [
+ "PyYAML==6.0.3",
+ "ruamel.yaml==0.19.1",
+]
+
+[project.scripts]
+precommit-updates = "precommit_updates.cli:main"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+testpaths = ["tests"]
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..8b09bce
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+PyYAML==6.0.3
+ruamel.yaml==0.19.1
\ No newline at end of file
diff --git a/src/precommit_updates/README.md b/src/precommit_updates/README.md
new file mode 100644
index 0000000..c6107d7
--- /dev/null
+++ b/src/precommit_updates/README.md
@@ -0,0 +1,61 @@
+# Pre-Commit Updates
+
+`precommit_updates` contains the Python implementation used by the
+`auto-update-precommit-hooks` GitHub Actions workflow.
+
+The package keeps update policy and file mutation testable while leaving job
+sequencing, permissions, and secrets in the workflow.
+
+## CLI stages
+
+Run the module from the repository root with `PYTHONPATH=src`:
+
+```shell
+PYTHONPATH=src python3 -m precommit_updates detect
+PYTHONPATH=src python3 -m precommit_updates cooldown
+PYTHONPATH=src python3 -m precommit_updates release-info
+PYTHONPATH=src python3 -m precommit_updates apply
+```
+
+The stages correspond to the workflow jobs:
+
+- `detect` finds newer tagged releases and resolves them to commit SHAs.
+- `cooldown` applies skip-list, force-update, and semver cooldown policy.
+- `release-info` adds release notes and commits from the requested comparison range.
+- `apply` updates the YAML and tracking files, creates one commit per hook, pushes
+ the branch, and creates the pull request.
+
+Each stage accepts optional paths:
+
+```shell
+PYTHONPATH=src python3 -m precommit_updates detect \
+ --config .pre-commit-config.yaml \
+ --tracking configs/precommit-update-tracking.json
+```
+
+## Workflow contracts
+
+The CLI reads and writes the following GitHub Actions environment values:
+
+- `UPDATES_JSON` for the cooldown stage
+- `ELIGIBLE_JSON` for the release-info stage
+- `RELEASE_INFO` and `SKIPPED_UPDATES` for the apply stage
+- `COOLDOWN_MAJOR`, `COOLDOWN_MINOR`, and `COOLDOWN_PATCH`
+- `FORCE_UPDATE` and `SKIP_HOOKS`
+
+When `GITHUB_OUTPUT` is set, stage output is written using the existing workflow
+keys: `updates_found`, `updates_json`, `eligible_updates`, `skipped_updates`,
+and `release_info`.
+
+## Development
+
+Install the runtime dependencies from the repository root and run the focused
+unit tests:
+
+```shell
+pip install -r requirements.txt
+python -m pytest tests/unit -q
+```
+
+The GitHub client is designed for mocked tests. Normal tests do not require
+GitHub credentials or network access.
diff --git a/src/precommit_updates/__init__.py b/src/precommit_updates/__init__.py
new file mode 100644
index 0000000..d60b117
--- /dev/null
+++ b/src/precommit_updates/__init__.py
@@ -0,0 +1 @@
+"""Pre-commit hook update automation."""
\ No newline at end of file
diff --git a/src/precommit_updates/__main__.py b/src/precommit_updates/__main__.py
new file mode 100644
index 0000000..d99afab
--- /dev/null
+++ b/src/precommit_updates/__main__.py
@@ -0,0 +1,7 @@
+"""Run the pre-commit update CLI as a Python module."""
+
+from .cli import main
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/precommit_updates/cli.py b/src/precommit_updates/cli.py
new file mode 100644
index 0000000..456e580
--- /dev/null
+++ b/src/precommit_updates/cli.py
@@ -0,0 +1,269 @@
+"""Command-line entrypoints for workflow stages."""
+
+from __future__ import annotations
+
+import argparse
+from datetime import datetime, timezone
+import json
+import os
+from pathlib import Path
+import subprocess
+from typing import Any
+
+from .detection import detect_updates
+from .github import GitHubClient
+from .models import CooldownConfig
+from .mutation import apply_updates
+from .policy import filter_updates
+from .pr_body import extract_hook_name, generate_pr_body
+from .release_info import enrich_updates
+from .validation import alignment_errors, initialize_tracking
+
+
+def _write_output(values: dict[str, Any]) -> None:
+ """Write step outputs to ``GITHUB_OUTPUT`` or standard output.
+
+ Args:
+ values: Output names and values to serialize as one-line JSON records.
+ """
+ output_path = os.environ.get("GITHUB_OUTPUT")
+ for key, value in values.items():
+ serialized = json.dumps(value, separators=(",", ":")) if not isinstance(value, str) else value
+ if output_path:
+ with open(output_path, "a") as output_file:
+ output_file.write(f"{key}={serialized}\n")
+ else:
+ print(f"{key}={serialized}")
+
+
+def _write_summary(markdown: str) -> None:
+ """Append Markdown to the GitHub Actions step summary when available."""
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
+ if summary_path:
+ try:
+ with open(summary_path, "a") as summary_file:
+ summary_file.write(f"{markdown.rstrip()}\n")
+ except OSError as error:
+ print(f"WARNING: Could not write GitHub step summary: {error}")
+
+
+def _write_notice(title: str, message: str) -> None:
+ """Emit a readable GitHub Actions notice, or a local equivalent."""
+ print(f"::notice title={title}::{message}")
+
+
+def _path(value: str) -> Path:
+ """Expand a command-line path value.
+
+ Args:
+ value: User-provided filesystem path.
+
+ Returns:
+ Expanded path object.
+ """
+ return Path(value).expanduser()
+
+
+def detect_command(args: argparse.Namespace) -> None:
+ """Run the update-detection workflow stage.
+
+ Args:
+ args: Parsed CLI arguments containing config and tracking paths.
+ """
+ updates = detect_updates(_path(args.config), _path(args.tracking), GitHubClient())
+ _write_output({"updates_found": "true" if updates else "false", "updates_json": updates})
+ if not updates:
+ _write_notice("No pre-commit updates", "All configured hooks are already current.")
+ _write_summary(
+ "## Pre-commit update check\n\n"
+ "> **No updates available**\n\n"
+ "All configured pre-commit hooks are already at their latest detected release."
+ )
+ return
+
+ rows = [
+ f"| `{update['repo']}` | `{update['old_version']}` | `{update['new_version']}` |"
+ for update in updates
+ ]
+ _write_summary(
+ "## Pre-commit update check\n\n"
+ f"> **{len(updates)} update(s) available**\n\n"
+ "| Hook | Current | Latest |\n| --- | --- | --- |\n"
+ + "\n".join(rows)
+ )
+
+
+def cooldown_command(args: argparse.Namespace) -> None:
+ """Run the cooldown-policy workflow stage.
+
+ Args:
+ args: Parsed CLI arguments containing the tracking path.
+ """
+ updates = json.loads(os.environ.get("UPDATES_JSON", "[]"))
+ tracking_path = _path(args.tracking)
+ tracking = json.loads(tracking_path.read_text()) if tracking_path.exists() else {"hooks": {}}
+ cooldown = CooldownConfig(
+ major=int(os.environ.get("COOLDOWN_MAJOR", "28")),
+ minor=int(os.environ.get("COOLDOWN_MINOR", "14")),
+ patch=int(os.environ.get("COOLDOWN_PATCH", "7")),
+ )
+ skip_hooks = [item.strip() for item in os.environ.get("SKIP_HOOKS", "").split(",") if item.strip()]
+ eligible, skipped = filter_updates(
+ updates,
+ tracking,
+ cooldown,
+ now=datetime.now(timezone.utc),
+ force_update=os.environ.get("FORCE_UPDATE", "false").lower() == "true",
+ skip_hooks=skip_hooks,
+ )
+ _write_output({"eligible_updates": eligible, "skipped_updates": skipped})
+ if not eligible:
+ _write_notice(
+ "No updates after cooldown",
+ f"{len(skipped)} available update(s) were skipped; downstream jobs will not run.",
+ )
+ _write_summary(
+ "## Cooldown filter\n\n"
+ f"| Result | Count |\n| --- | ---: |\n| Eligible | {len(eligible)} |\n"
+ f"| Skipped | {len(skipped)} |\n\n"
+ + (
+ "> **No eligible updates**\n\n"
+ "The workflow stopped before release enrichment and pull request creation."
+ if not eligible
+ else "> Eligible updates will continue to release enrichment."
+ )
+ )
+
+
+def release_info_command(args: argparse.Namespace) -> None:
+ """Run the release-enrichment workflow stage.
+
+ Args:
+ args: Parsed CLI arguments; update data is read from ``ELIGIBLE_JSON``.
+ """
+ updates = json.loads(os.environ.get("ELIGIBLE_JSON", "[]"))
+ _write_output({"release_info": enrich_updates(updates, GitHubClient())})
+
+
+def apply_command(args: argparse.Namespace) -> None:
+ """Apply updates, commit them, push a branch, and create a pull request.
+
+ Args:
+ args: Parsed CLI arguments containing config and tracking paths.
+
+ Raises:
+ SystemExit: If a git or GitHub CLI command fails.
+ """
+ release_info = json.loads(os.environ.get("RELEASE_INFO", "[]"))
+ skipped = json.loads(os.environ.get("SKIPPED_UPDATES", "[]"))
+ if not release_info:
+ print("No updates to apply")
+ return
+
+ cooldown = {
+ "major": int(os.environ.get("COOLDOWN_MAJOR", "28")),
+ "minor": int(os.environ.get("COOLDOWN_MINOR", "14")),
+ "patch": int(os.environ.get("COOLDOWN_PATCH", "7")),
+ }
+ body = generate_pr_body(
+ release_info,
+ skipped,
+ cooldown,
+ force_update=os.environ.get("FORCE_UPDATE", "false").lower() == "true",
+ actor=os.environ.get("GITHUB_ACTOR", "Workflow"),
+ run_id=os.environ.get("GITHUB_RUN_ID", "local"),
+ repository=os.environ.get("GITHUB_REPOSITORY", "unknown"),
+ server_url=os.environ.get("GITHUB_SERVER_URL", "https://github.com"),
+ )
+ branch = f"chore/precommit-updates-{datetime.now(timezone.utc):%Y%m%d}"
+ setup_commands = [
+ ["git", "config", "user.name", "github-actions[bot]"],
+ ["git", "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"],
+ ["git", "checkout", "-b", branch],
+ ]
+
+ for command in setup_commands:
+ result = subprocess.run(command, text=True, capture_output=True)
+ if result.returncode != 0:
+ raise SystemExit(result.stderr or f"Command failed: {' '.join(command[:3])}")
+
+ for update in release_info:
+ apply_updates(_path(args.config), _path(args.tracking), [update])
+ hook_name = extract_hook_name(update["repo"])
+ commands = [
+ ["git", "add", str(args.config), str(args.tracking)],
+ [
+ "git",
+ "commit",
+ "-m",
+ f"chore(pre-commit): update {hook_name} to {update['new_version']}",
+ ],
+ ]
+ for command in commands:
+ result = subprocess.run(command, text=True, capture_output=True)
+ if result.returncode != 0:
+ raise SystemExit(result.stderr or f"Command failed: {' '.join(command[:3])}")
+
+ final_commands = [
+ ["gh", "auth", "setup-git"],
+ ["git", "push", "--force-with-lease", "-u", "origin", branch],
+ [
+ "gh",
+ "pr",
+ "create",
+ "--base",
+ "main",
+ "--head",
+ branch,
+ "--title",
+ f"chore(pre-commit): auto-update hooks ({len(release_info)} update(s))",
+ "--body",
+ body,
+ ],
+ ]
+ for command in final_commands:
+ result = subprocess.run(command, text=True, capture_output=True)
+ if result.returncode != 0:
+ raise SystemExit(result.stderr or f"Command failed: {' '.join(command[:3])}")
+
+
+def validate_command(args: argparse.Namespace) -> None:
+ """Validate pre-commit configuration and tracking alignment."""
+ tracking_path = _path(args.tracking)
+ if not tracking_path.exists():
+ print(f"WARNING: {tracking_path} not found; creating baseline tracking state")
+ initialize_tracking(_path(args.config), tracking_path)
+ return
+
+ errors = alignment_errors(_path(args.config), tracking_path)
+ if errors:
+ for error in errors:
+ print(f"ERROR: {error}")
+ raise SystemExit(1)
+ print("Pre-commit configuration and tracking state are aligned")
+
+
+def _parser() -> argparse.ArgumentParser:
+ """Build the command-line parser for all workflow stages.
+
+ Returns:
+ Configured argument parser.
+ """
+ parser = argparse.ArgumentParser(prog="precommit-updates")
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ for name in ("detect", "cooldown", "release-info", "apply", "validate"):
+ subparser = subparsers.add_parser(name)
+ subparser.add_argument("--config", default=".pre-commit-config.yaml")
+ subparser.add_argument("--tracking", default="configs/precommit-update-tracking.json")
+ subparser.set_defaults(handler=globals()[f"{name.replace('-', '_')}_command"])
+ return parser
+
+
+def main() -> None:
+ """Parse arguments and dispatch the selected workflow stage."""
+ args = _parser().parse_args()
+ args.handler(args)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/src/precommit_updates/detection.py b/src/precommit_updates/detection.py
new file mode 100644
index 0000000..a23ed46
--- /dev/null
+++ b/src/precommit_updates/detection.py
@@ -0,0 +1,70 @@
+"""Discover commit-pinned pre-commit hook updates."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from .github import GitHubClient
+from .models import determine_semver_level
+
+
+def detect_updates(
+ config_path: Path,
+ tracking_path: Path,
+ github: GitHubClient,
+) -> list[dict[str, Any]]:
+ """Return available tagged-release updates for configured hooks.
+
+ Args:
+ config_path: Path to the commit-pinned pre-commit configuration.
+ tracking_path: Path to durable update tracking state.
+ github: Client used to query upstream releases and tags.
+
+ Returns:
+ Update records containing old/new SHAs, versions, semver level, and range.
+ """
+ with config_path.open() as config_file:
+ config = yaml.safe_load(config_file) or {}
+
+ tracking_data: dict[str, Any] = {}
+ if tracking_path.exists():
+ with tracking_path.open() as tracking_file:
+ tracking_data = json.load(tracking_file).get("hooks", {})
+
+ updates: list[dict[str, Any]] = []
+ for repo_entry in config.get("repos", []):
+ repo_url = repo_entry.get("repo")
+ current_sha = repo_entry.get("rev")
+ if not repo_url or not current_sha:
+ continue
+
+ release_info = github.latest_release(repo_url)
+ if not release_info:
+ continue
+ latest_tag, latest_sha = release_info
+ if latest_sha == current_sha:
+ continue
+
+ old_version = tracking_data.get(repo_url, {}).get("current_version")
+ if not old_version:
+ old_version = github.tag_for_sha(repo_url, current_sha)
+ old_version = old_version or current_sha[:7]
+ if old_version == latest_tag:
+ continue
+
+ updates.append(
+ {
+ "repo": repo_url,
+ "old_sha": current_sha,
+ "new_sha": latest_sha,
+ "old_version": old_version,
+ "new_version": latest_tag,
+ "semver_level": determine_semver_level(old_version, latest_tag),
+ "commit_range": f"{current_sha}...{latest_sha}",
+ }
+ )
+ return updates
\ No newline at end of file
diff --git a/src/precommit_updates/github.py b/src/precommit_updates/github.py
new file mode 100644
index 0000000..1412fb8
--- /dev/null
+++ b/src/precommit_updates/github.py
@@ -0,0 +1,149 @@
+"""GitHub CLI adapter used by the update pipeline."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import re
+import subprocess
+from typing import Callable, Optional, Sequence
+
+
+CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
+_REPOSITORY_PATTERN = re.compile(r"^https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$")
+
+
+@dataclass(frozen=True)
+class GitHubRepository:
+ """Parsed owner and repository name from a GitHub URL.
+
+ Attributes:
+ owner: GitHub account or organization name.
+ name: GitHub repository name.
+ """
+
+ owner: str
+ name: str
+
+ @property
+ def path(self) -> str:
+ """Return the ``owner/name`` path used by the GitHub API."""
+ return f"{self.owner}/{self.name}"
+
+
+def parse_repository_url(repo_url: str) -> Optional[GitHubRepository]:
+ """Parse a HTTPS GitHub repository URL.
+
+ Args:
+ repo_url: Repository URL, optionally ending in ``.git`` or ``/``.
+
+ Returns:
+ A parsed repository, or ``None`` for a non-GitHub or malformed URL.
+ """
+ match = _REPOSITORY_PATTERN.fullmatch(repo_url.strip())
+ if not match:
+ return None
+ return GitHubRepository(*match.groups())
+
+
+class GitHubClient:
+ """Run read-only GitHub API queries through the ``gh`` executable."""
+
+ def __init__(self, runner: CommandRunner | None = None, timeout: int = 10) -> None:
+ """Initialize a GitHub CLI client.
+
+ Args:
+ runner: Callable used to execute commands; defaults to ``subprocess.run``.
+ timeout: Maximum seconds allowed for each GitHub API request.
+ """
+ self._runner = runner or subprocess.run
+ self._timeout = timeout
+
+ def _query(self, args: Sequence[str]) -> Optional[str]:
+ """Run a read-only ``gh api`` query.
+
+ Args:
+ args: Arguments appended after ``gh api``.
+
+ Returns:
+ Stripped standard output, or ``None`` after a command failure or timeout.
+ """
+ try:
+ result = self._runner(
+ ["gh", "api", *args],
+ capture_output=True,
+ text=True,
+ timeout=self._timeout,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ if result.returncode != 0 or not result.stdout.strip():
+ return None
+ return result.stdout.strip()
+
+ def latest_release(self, repo_url: str) -> Optional[tuple[str, str]]:
+ """Return the latest release tag and its resolved commit SHA.
+
+ Args:
+ repo_url: Upstream GitHub repository URL.
+
+ Returns:
+ A ``(tag, sha)`` pair, or ``None`` when the repository has no readable release.
+ """
+ repository = parse_repository_url(repo_url)
+ if not repository:
+ return None
+ tag = self._query([f"repos/{repository.path}/releases/latest", "-q", ".tag_name"])
+ if not tag:
+ return None
+ sha = self._query([f"repos/{repository.path}/commits/{tag}", "-q", ".sha"])
+ return (tag, sha) if sha else None
+
+ def tag_for_sha(self, repo_url: str, sha: str) -> Optional[str]:
+ """Find a tag pointing at a commit SHA.
+
+ Args:
+ repo_url: Upstream GitHub repository URL.
+ sha: Commit SHA to resolve.
+
+ Returns:
+ The first matching tag, or ``None`` when no tag is found.
+ """
+ repository = parse_repository_url(repo_url)
+ if not repository:
+ return None
+ query = f'.[] | select(.commit.sha == "{sha}") | .name'
+ tags = self._query(["--paginate", f"repos/{repository.path}/tags", "-q", query])
+ return tags.splitlines()[0] if tags else None
+
+ def release_notes(self, repo_url: str, tag: str) -> Optional[str]:
+ """Fetch release notes for a tag.
+
+ Args:
+ repo_url: Upstream GitHub repository URL.
+ tag: Release tag to query.
+
+ Returns:
+ Release body text, or ``None`` when it cannot be fetched.
+ """
+ repository = parse_repository_url(repo_url)
+ if not repository:
+ return None
+ return self._query([f"repos/{repository.path}/releases/tags/{tag}", "-q", ".body"])
+
+ def commit_messages(self, repo_url: str, old_sha: str, new_sha: str) -> list[str]:
+ """Fetch up to ten commits in an inclusive GitHub comparison range.
+
+ Args:
+ repo_url: Upstream GitHub repository URL.
+ old_sha: Base commit for the comparison.
+ new_sha: Head commit for the comparison.
+
+ Returns:
+ Commit SHAs from ``old_sha...new_sha``, truncated to ten entries.
+ """
+ repository = parse_repository_url(repo_url)
+ if not repository:
+ return []
+ comparison = f"repos/{repository.path}/compare/{old_sha}...{new_sha}"
+ commits = self._query([comparison, "-q", ".commits[].sha"])
+ return commits.splitlines()[:10] if commits else []
\ No newline at end of file
diff --git a/src/precommit_updates/models.py b/src/precommit_updates/models.py
new file mode 100644
index 0000000..9e26bbc
--- /dev/null
+++ b/src/precommit_updates/models.py
@@ -0,0 +1,112 @@
+"""Domain values and pure version logic for pre-commit updates."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import re
+from typing import Optional
+
+
+Version = tuple[int, int, int]
+SEMVER_LEVELS = ("major", "minor", "patch")
+_VERSION_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$")
+_SHA_PATTERN = re.compile(r"^[0-9a-f]{7,40}$", re.IGNORECASE)
+
+
+@dataclass(frozen=True)
+class CooldownConfig:
+ """Validated cooldown durations, in whole days.
+
+ Attributes:
+ major: Minimum days before a major update is eligible.
+ minor: Minimum days before a minor update is eligible.
+ patch: Minimum days before a patch update is eligible.
+ """
+
+ major: int = 28
+ minor: int = 14
+ patch: int = 7
+
+ def __post_init__(self) -> None:
+ """Validate that cooldown values are non-negative integers.
+
+ Raises:
+ ValueError: If any cooldown is not an integer or is negative.
+ """
+ values = {"major": self.major, "minor": self.minor, "patch": self.patch}
+ if any(not isinstance(value, int) or isinstance(value, bool) for value in values.values()):
+ raise ValueError("cooldown periods must be integers")
+ if any(value < 0 for value in values.values()):
+ raise ValueError("cooldown periods cannot be negative")
+
+ def for_level(self, level: str) -> int:
+ """Return the configured duration for a semantic version level.
+
+ Args:
+ level: One of ``major``, ``minor``, or ``patch``.
+
+ Returns:
+ The cooldown duration in days.
+
+ Raises:
+ ValueError: If ``level`` is unsupported.
+ """
+ if level not in SEMVER_LEVELS:
+ raise ValueError(f"unsupported semver level: {level}")
+ return getattr(self, level)
+
+ def as_dict(self) -> dict[str, int]:
+ """Return cooldown values keyed by semantic version level.
+
+ Returns:
+ A new dictionary containing the major, minor, and patch durations.
+ """
+ return {level: self.for_level(level) for level in SEMVER_LEVELS}
+
+
+def parse_version(tag: str) -> Optional[Version]:
+ """Parse a semantic version tag such as ``v1.2.3``.
+
+ Args:
+ tag: Version tag with an optional ``v`` prefix and prerelease/build suffix.
+
+ Returns:
+ A ``(major, minor, patch)`` tuple, or ``None`` for an invalid tag.
+ """
+ match = _VERSION_PATTERN.fullmatch(tag.strip())
+ if not match:
+ return None
+ return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
+
+
+def determine_semver_level(old_version: str, new_version: str) -> str:
+ """Classify a version change.
+
+ Args:
+ old_version: Existing semantic version tag.
+ new_version: Candidate semantic version tag.
+
+ Returns:
+ ``major``, ``minor``, ``patch``, or ``unknown`` when either version is invalid.
+ """
+ old = parse_version(old_version)
+ new = parse_version(new_version)
+ if not old or not new:
+ return "unknown"
+ if old[0] != new[0]:
+ return "major"
+ if old[1] != new[1]:
+ return "minor"
+ return "patch"
+
+
+def is_sha_like_version(version: str) -> bool:
+ """Return whether a version value looks like a commit SHA.
+
+ Args:
+ version: Version or commit identifier to inspect.
+
+ Returns:
+ ``True`` when the value contains 7 to 40 hexadecimal characters.
+ """
+ return bool(_SHA_PATTERN.fullmatch(version.strip()))
\ No newline at end of file
diff --git a/src/precommit_updates/mutation.py b/src/precommit_updates/mutation.py
new file mode 100644
index 0000000..ff13021
--- /dev/null
+++ b/src/precommit_updates/mutation.py
@@ -0,0 +1,86 @@
+"""Apply approved update data to repository configuration and tracking state."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+import json
+from pathlib import Path
+from typing import Any
+
+from ruamel.yaml import YAML
+
+
+def apply_updates(
+ config_path: Path,
+ tracking_path: Path,
+ updates: list[dict[str, Any]],
+ *,
+ now: datetime | None = None,
+) -> None:
+ """Update hook revisions and durable tracking state in place.
+
+ Args:
+ config_path: Path to the YAML pre-commit configuration.
+ tracking_path: Path to the JSON tracking state.
+ updates: Enriched update records to apply.
+ now: Optional timezone-aware timestamp used for tracking fields.
+
+ Raises:
+ ValueError: If ``now`` is timezone-naive.
+ OSError: If either input or output file cannot be accessed.
+ json.JSONDecodeError: If existing tracking state is invalid JSON.
+ """
+ if now is None:
+ now = datetime.now(timezone.utc)
+ if now.tzinfo is None:
+ raise ValueError("now must be timezone-aware")
+
+ yaml = YAML()
+ yaml.preserve_quotes = True
+ yaml.default_flow_style = False
+ with config_path.open() as config_file:
+ config = yaml.load(config_file)
+
+ if tracking_path.exists():
+ with tracking_path.open() as tracking_file:
+ tracking = json.load(tracking_file)
+ else:
+ tracking = {"last_updated": now.isoformat(), "hooks": {}}
+ tracking.setdefault("hooks", {})
+
+ update_map = {update["repo"]: update for update in updates}
+ timestamp = now.isoformat()
+ for repo_entry in config.get("repos", []):
+ repo_url = repo_entry.get("repo")
+ update = update_map.get(repo_url)
+ if not update:
+ continue
+
+ repo_entry["rev"] = update["new_sha"]
+ if hasattr(repo_entry, "ca"):
+ repo_entry.yaml_add_eol_comment(
+ f'frozen: {update["new_version"]}', key="rev"
+ )
+
+ hook = tracking["hooks"].setdefault(
+ repo_url,
+ {
+ "current_sha": update["new_sha"],
+ "current_version": update["new_version"],
+ "semver_levels": {level: timestamp for level in ("major", "minor", "patch")},
+ },
+ )
+ hook["current_sha"] = update["new_sha"]
+ hook["current_version"] = update["new_version"]
+ hook.setdefault("semver_levels", {})
+ level = update.get("semver_level")
+ if level in ("major", "minor", "patch"):
+ hook["semver_levels"][level] = timestamp
+ hook["last_updated"] = timestamp
+
+ tracking["last_updated"] = timestamp
+ with config_path.open("w") as config_file:
+ yaml.dump(config, config_file)
+ with tracking_path.open("w") as tracking_file:
+ json.dump(tracking, tracking_file, indent=2)
+ tracking_file.write("\n")
\ No newline at end of file
diff --git a/src/precommit_updates/policy.py b/src/precommit_updates/policy.py
new file mode 100644
index 0000000..b7dfb28
--- /dev/null
+++ b/src/precommit_updates/policy.py
@@ -0,0 +1,79 @@
+"""Cooldown and skip-list policy for detected updates."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Iterable
+
+from .models import CooldownConfig, SEMVER_LEVELS
+
+
+def filter_updates(
+ updates: Iterable[dict[str, Any]],
+ tracking: dict[str, Any],
+ cooldown: CooldownConfig,
+ *,
+ now: datetime,
+ force_update: bool = False,
+ skip_hooks: Iterable[str] = (),
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Apply skip-list, force-update, and cooldown policy.
+
+ Args:
+ updates: Candidate update records.
+ tracking: Durable state keyed by repository URL.
+ cooldown: Validated cooldown durations.
+ now: Time against which cooldown timestamps are evaluated.
+ force_update: Whether to bypass cooldown timestamps.
+ skip_hooks: Repository URLs excluded even during forced updates.
+
+ Returns:
+ A pair of eligible records and skipped records with human-readable reasons.
+
+ Raises:
+ ValueError: If ``now`` is timezone-naive.
+ """
+ if now.tzinfo is None:
+ raise ValueError("now must be timezone-aware")
+
+ skipped_repos = set(skip_hooks)
+ eligible: list[dict[str, Any]] = []
+ skipped: list[dict[str, Any]] = []
+
+ for update in updates:
+ repo = update["repo"]
+ if repo in skipped_repos:
+ skipped.append({**update, "reason": "Hook in skip list"})
+ continue
+
+ level = update.get("semver_level")
+ if level not in SEMVER_LEVELS:
+ skipped.append({**update, "reason": "Unsupported semantic version level"})
+ continue
+
+ if not force_update:
+ timestamp = tracking.get("hooks", {}).get(repo, {}).get("semver_levels", {}).get(level)
+ if timestamp:
+ try:
+ last_updated = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
+ if last_updated.tzinfo is None:
+ raise ValueError("timestamp must be timezone-aware")
+ except (TypeError, ValueError):
+ skipped.append({**update, "reason": "Invalid cooldown timestamp"})
+ continue
+
+ elapsed_days = (now.astimezone(timezone.utc) - last_updated.astimezone(timezone.utc)).days
+ required_days = cooldown.for_level(level)
+ if elapsed_days < required_days:
+ skipped.append(
+ {
+ **update,
+ "reason": f"Cooldown active: {elapsed_days}/{required_days} days",
+ "days_remaining": required_days - elapsed_days,
+ }
+ )
+ continue
+
+ eligible.append({**update, "cooldown_applied": cooldown.as_dict()})
+
+ return eligible, skipped
\ No newline at end of file
diff --git a/src/precommit_updates/pr_body.py b/src/precommit_updates/pr_body.py
new file mode 100644
index 0000000..1a1d5e2
--- /dev/null
+++ b/src/precommit_updates/pr_body.py
@@ -0,0 +1,78 @@
+"""Render the review body for an automated hook-update pull request."""
+
+from __future__ import annotations
+
+import re
+from datetime import datetime, timezone
+from typing import Any
+
+
+def extract_hook_name(repo_url: str) -> str:
+ """Extract the final repository component from a URL.
+
+ Args:
+ repo_url: Repository URL or fallback display value.
+
+ Returns:
+ Repository name without a trailing ``.git`` suffix when present.
+ """
+ match = re.search(r"/([^/]+?)(?:\.git)?$", repo_url)
+ return match.group(1) if match else repo_url
+
+
+def generate_pr_body(
+ updates: list[dict[str, Any]],
+ skipped: list[dict[str, Any]],
+ cooldown_config: dict[str, int],
+ *,
+ force_update: bool = False,
+ actor: str = "Workflow",
+ run_id: str = "local",
+ repository: str = "unknown",
+ server_url: str = "https://github.com",
+ now: datetime | None = None,
+) -> str:
+ """Render the markdown body for an update pull request.
+
+ Args:
+ updates: Applied update records and optional release context.
+ skipped: Updates excluded by policy.
+ cooldown_config: Cooldown durations shown in the policy summary.
+ force_update: Whether the force-update warning is included.
+ actor: Workflow actor shown in the summary.
+ run_id: GitHub Actions run identifier.
+ repository: Full GitHub repository name.
+ server_url: GitHub server base URL.
+ now: Optional timestamp used for deterministic rendering.
+
+ Returns:
+ Markdown suitable for the pull request body.
+ """
+ now = now or datetime.now(timezone.utc)
+ lines = ["## Summary", "", f"{actor} ran on {now.strftime('%Y-%m-%d %H:%M:%S UTC')} and updated **{len(updates)} hook(s)**.", "", f"**Workflow run**: [{run_id}]({server_url}/{repository}/actions/runs/{run_id})", "", "---", "", "## Changes", ""]
+
+ for update in updates:
+ level = update.get("semver_level", "unknown").upper()
+ lines.extend([f"### {extract_hook_name(update['repo'])} `[{level}]`", f"`{update['old_version']}` -> `{update['new_version']}`", ""])
+ commits = update.get("commits", [])
+ if commits:
+ lines.extend([f"Commits ({update.get('commit_count', len(commits))})
", "", "```"])
+ lines.extend(f"- {commit[:7]}" for commit in commits[:10])
+ lines.extend(["```", "", f"[View commit history]({update['repo']}/compare/{update['old_sha'][:7]}...{update['new_sha'][:7]})", "", " ", ""])
+ notes = update.get("release_notes", "(No release notes)")
+ if notes and notes != "(No release notes available)":
+ lines.extend(["Release Notes
", "", notes, "", " ", ""])
+
+ lines.extend(["---", "", "## Risks & Notes", ""])
+ if any(update.get("semver_level") == "major" for update in updates):
+ lines.extend(["> [!WARNING]", "> Major Version Updates", ">", "> Major versions may introduce breaking changes. Reviewers should examine the release notes and commit history carefully.", ""])
+ if any(update.get("cooldown_applied", {}).get(update.get("semver_level"), 0) < 7 for update in updates):
+ lines.extend(["> [!WARNING]", "> Short Cooldown Period", ">", "> Some updates have cooldown periods less than 7 days, which may increase vulnerability to supply chain attacks.", ""])
+ if force_update:
+ lines.extend(["### Update Policy", "", "> [!NOTE]", "> **Force Update Override**", ">", "> Cooldown periods were bypassed via `force_update: true`.", ""])
+ else:
+ lines.extend(["### Cooldown Periods Applied", "", f"- **Major versions**: {cooldown_config['major']} days", f"- **Minor versions**: {cooldown_config['minor']} days", f"- **Patch versions**: {cooldown_config['patch']} days", ""])
+ if skipped:
+ lines.extend(["### Skipped Updates", "", "The following updates are available but skipped due to policy:", ""])
+ lines.extend(f"- **{extract_hook_name(update['repo'])}**: {update.get('reason', 'Policy filtered') }" for update in skipped)
+ return "\n".join(lines)
\ No newline at end of file
diff --git a/src/precommit_updates/release_info.py b/src/precommit_updates/release_info.py
new file mode 100644
index 0000000..13e3778
--- /dev/null
+++ b/src/precommit_updates/release_info.py
@@ -0,0 +1,34 @@
+"""Enrich eligible updates with upstream release context."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from .github import GitHubClient
+from .models import is_sha_like_version
+
+
+def enrich_updates(updates: list[dict[str, Any]], github: GitHubClient) -> list[dict[str, Any]]:
+ """Add release notes and bounded commit information to updates.
+
+ Args:
+ updates: Eligible update records containing repository and SHA fields.
+ github: Client used to query release metadata.
+
+ Returns:
+ New update records with release notes, commit SHAs, and commit counts.
+ """
+ enriched: list[dict[str, Any]] = []
+ for update in updates:
+ tag = update["new_version"]
+ release_notes = None if is_sha_like_version(tag) else github.release_notes(update["repo"], tag)
+ commits = github.commit_messages(update["repo"], update["old_sha"], update["new_sha"])
+ enriched.append(
+ {
+ **update,
+ "release_notes": release_notes or "(No release notes available)",
+ "commits": commits,
+ "commit_count": len(commits),
+ }
+ )
+ return enriched
\ No newline at end of file
diff --git a/src/precommit_updates/validation.py b/src/precommit_updates/validation.py
new file mode 100644
index 0000000..d062eec
--- /dev/null
+++ b/src/precommit_updates/validation.py
@@ -0,0 +1,67 @@
+"""Validate pre-commit configuration against update tracking state."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+def initialize_tracking(config_path: Path, tracking_path: Path) -> None:
+ """Create baseline tracking state from configured hook revisions."""
+ with config_path.open() as config_file:
+ config = yaml.safe_load(config_file) or {}
+
+ tracking = {
+ "last_updated": datetime.now(timezone.utc).isoformat(),
+ "hooks": {
+ entry["repo"]: {
+ "current_sha": entry["rev"],
+ "semver_levels": {},
+ }
+ for entry in config.get("repos", [])
+ if entry.get("repo") and entry.get("rev")
+ },
+ }
+ with tracking_path.open("w") as tracking_file:
+ json.dump(tracking, tracking_file, indent=2)
+ tracking_file.write("\n")
+
+
+def alignment_errors(config_path: Path, tracking_path: Path) -> list[str]:
+ """Return errors when configured hooks and tracking state are out of sync.
+
+ Args:
+ config_path: Path to the pre-commit configuration.
+ tracking_path: Path to the update tracking JSON file.
+
+ Returns:
+ Human-readable alignment errors. An empty list means the files align.
+ """
+ with config_path.open() as config_file:
+ config = yaml.safe_load(config_file) or {}
+ with tracking_path.open() as tracking_file:
+ tracking = json.load(tracking_file)
+
+ configured = {
+ entry.get("repo"): entry
+ for entry in config.get("repos", [])
+ if entry.get("repo") and entry.get("rev")
+ }
+ tracked: dict[str, Any] = tracking.get("hooks", {})
+ errors: list[str] = []
+
+ for repo in sorted(set(configured) - set(tracked)):
+ errors.append(f"missing tracking entry: {repo}")
+ for repo in sorted(set(tracked) - set(configured)):
+ errors.append(f"extra tracking entry: {repo}")
+ for repo in sorted(set(configured) & set(tracked)):
+ configured_sha = configured[repo]["rev"]
+ tracked_sha = tracked[repo].get("current_sha")
+ if configured_sha != tracked_sha:
+ errors.append(f"SHA mismatch for {repo}: config={configured_sha}, tracking={tracked_sha}")
+
+ return errors
\ No newline at end of file
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
new file mode 100644
index 0000000..9ffc4fa
--- /dev/null
+++ b/tests/unit/test_cli.py
@@ -0,0 +1,82 @@
+import json
+
+from precommit_updates import cli
+
+
+def test_write_output_preserves_workflow_keys_and_json(tmp_path, monkeypatch):
+ output = tmp_path / "github-output"
+ monkeypatch.setenv("GITHUB_OUTPUT", str(output))
+
+ cli._write_output({"updates_found": "true", "updates_json": [{"repo": "example"}]})
+
+ assert output.read_text().splitlines() == [
+ "updates_found=true",
+ 'updates_json=[{"repo":"example"}]',
+ ]
+
+
+def test_write_summary_appends_markdown(tmp_path, monkeypatch):
+ summary = tmp_path / "summary"
+ monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
+
+ cli._write_summary("## Status\n\nAll clear")
+
+ assert summary.read_text() == "## Status\n\nAll clear\n"
+
+
+def test_cooldown_command_reads_workflow_environment(tmp_path, monkeypatch):
+ tracking = tmp_path / "tracking.json"
+ tracking.write_text(json.dumps({"hooks": {}}))
+ output = tmp_path / "github-output"
+ monkeypatch.setenv("GITHUB_OUTPUT", str(output))
+ monkeypatch.setenv("UPDATES_JSON", json.dumps([{"repo": "example", "semver_level": "patch"}]))
+
+ cli.cooldown_command(type("Args", (), {"tracking": str(tracking)})())
+
+ values = dict(line.split("=", 1) for line in output.read_text().splitlines())
+ assert json.loads(values["eligible_updates"])[0]["repo"] == "example"
+ assert json.loads(values["skipped_updates"]) == []
+
+
+def test_validate_command_accepts_aligned_files(tmp_path, capsys):
+ config = tmp_path / "pre-commit-config.yaml"
+ tracking = tmp_path / "tracking.json"
+ repo = "https://github.com/example/hooks"
+ sha = "a" * 40
+ config.write_text(f"repos:\n - repo: {repo}\n rev: {sha}\n")
+ tracking.write_text(json.dumps({"hooks": {repo: {"current_sha": sha}}}))
+
+ cli.validate_command(type("Args", (), {"config": str(config), "tracking": str(tracking)})())
+
+ assert "aligned" in capsys.readouterr().out
+
+
+def test_validate_command_rejects_sha_mismatch(tmp_path, capsys):
+ config = tmp_path / "pre-commit-config.yaml"
+ tracking = tmp_path / "tracking.json"
+ repo = "https://github.com/example/hooks"
+ config.write_text(f"repos:\n - repo: {repo}\n rev: {'a' * 40}\n")
+ tracking.write_text(json.dumps({"hooks": {repo: {"current_sha": "b" * 40}}}))
+
+ try:
+ cli.validate_command(type("Args", (), {"config": str(config), "tracking": str(tracking)})())
+ except SystemExit as error:
+ assert error.code == 1
+ else:
+ raise AssertionError("expected validation to fail")
+
+ assert "SHA mismatch" in capsys.readouterr().out
+
+
+def test_validate_command_initializes_missing_tracking_file(tmp_path, capsys):
+ config = tmp_path / "pre-commit-config.yaml"
+ tracking = tmp_path / "tracking.json"
+ repo = "https://github.com/example/hooks"
+ sha = "a" * 40
+ config.write_text(f"repos:\n - repo: {repo}\n rev: {sha}\n")
+
+ cli.validate_command(type("Args", (), {"config": str(config), "tracking": str(tracking)})())
+
+ state = json.loads(tracking.read_text())
+ assert state["hooks"][repo]["current_sha"] == sha
+ assert "not found; creating baseline" in capsys.readouterr().out
\ No newline at end of file
diff --git a/tests/unit/test_github.py b/tests/unit/test_github.py
new file mode 100644
index 0000000..e4427db
--- /dev/null
+++ b/tests/unit/test_github.py
@@ -0,0 +1,36 @@
+from types import SimpleNamespace
+
+from precommit_updates.github import GitHubClient, parse_repository_url
+
+
+def test_parse_repository_url_accepts_git_suffix_and_rejects_other_hosts():
+ assert parse_repository_url("https://github.com/example/hook.git").path == "example/hook"
+ assert parse_repository_url("https://gitlab.com/example/hook") is None
+
+
+def test_commit_messages_use_compare_range():
+ calls = []
+
+ def runner(command, **kwargs):
+ calls.append((command, kwargs))
+ return SimpleNamespace(returncode=0, stdout="abc\ndef\n", stderr="")
+
+ commits = GitHubClient(runner=runner).commit_messages(
+ "https://github.com/example/hook", "oldsha", "newsha"
+ )
+
+ assert commits == ["abc", "def"]
+ assert calls[0][0] == [
+ "gh",
+ "api",
+ "repos/example/hook/compare/oldsha...newsha",
+ "-q",
+ ".commits[].sha",
+ ]
+
+
+def test_failed_github_command_is_treated_as_missing_data():
+ def runner(command, **kwargs):
+ return SimpleNamespace(returncode=1, stdout="", stderr="failure")
+
+ assert GitHubClient(runner=runner).latest_release("https://github.com/example/hook") is None
\ No newline at end of file
diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py
new file mode 100644
index 0000000..8a434a4
--- /dev/null
+++ b/tests/unit/test_models.py
@@ -0,0 +1,34 @@
+import pytest
+
+from precommit_updates.models import (
+ CooldownConfig,
+ determine_semver_level,
+ is_sha_like_version,
+ parse_version,
+)
+
+
+@pytest.mark.parametrize(
+ ("tag", "expected"),
+ [("v1.2.3", (1, 2, 3)), ("1.2.3", (1, 2, 3)), ("v1.2.3-rc.1", (1, 2, 3)), ("1.2", None)],
+)
+def test_parse_version(tag, expected):
+ assert parse_version(tag) == expected
+
+
+@pytest.mark.parametrize(
+ ("old", "new", "level"),
+ [("v1.2.3", "v2.0.0", "major"), ("v1.2.3", "v1.3.0", "minor"), ("v1.2.3", "v1.2.4", "patch"), ("sha", "v1.2.4", "unknown")],
+)
+def test_determine_semver_level(old, new, level):
+ assert determine_semver_level(old, new) == level
+
+
+def test_sha_detection_is_generic():
+ assert is_sha_like_version("a" * 40)
+ assert not is_sha_like_version("v1.2.3")
+
+
+def test_cooldown_config_rejects_negative_values():
+ with pytest.raises(ValueError, match="negative"):
+ CooldownConfig(patch=-1)
\ No newline at end of file
diff --git a/tests/unit/test_mutation.py b/tests/unit/test_mutation.py
new file mode 100644
index 0000000..e7c27c6
--- /dev/null
+++ b/tests/unit/test_mutation.py
@@ -0,0 +1,25 @@
+import json
+from datetime import datetime, timezone
+
+from precommit_updates.mutation import apply_updates
+
+
+def test_apply_updates_preserves_yaml_comments_and_updates_only_changed_level(tmp_path):
+ config_path = tmp_path / ".pre-commit-config.yaml"
+ tracking_path = tmp_path / "tracking.json"
+ config_path.write_text(
+ "repos:\n - repo: https://github.com/example/hook\n rev: oldsha # frozen: v1.0.0\n hooks: []\n"
+ )
+ tracking_path.write_text(json.dumps({"hooks": {"https://github.com/example/hook": {"semver_levels": {"major": "old", "minor": "old", "patch": "old"}}}}))
+
+ apply_updates(
+ config_path,
+ tracking_path,
+ [{"repo": "https://github.com/example/hook", "new_sha": "newsha", "new_version": "v1.0.1", "semver_level": "patch"}],
+ now=datetime(2026, 9, 17, tzinfo=timezone.utc),
+ )
+
+ assert "rev: newsha # frozen: v1.0.1" in config_path.read_text()
+ tracking = json.loads(tracking_path.read_text())
+ assert tracking["hooks"]["https://github.com/example/hook"]["semver_levels"]["patch"] == "2026-09-17T00:00:00+00:00"
+ assert tracking["hooks"]["https://github.com/example/hook"]["semver_levels"]["major"] == "old"
\ No newline at end of file
diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py
new file mode 100644
index 0000000..43e7ecb
--- /dev/null
+++ b/tests/unit/test_policy.py
@@ -0,0 +1,59 @@
+from datetime import datetime, timezone, timedelta
+
+import pytest
+
+from precommit_updates.models import CooldownConfig
+from precommit_updates.policy import filter_updates
+
+
+NOW = datetime(2026, 9, 17, tzinfo=timezone.utc)
+UPDATE = {"repo": "https://github.com/example/hook", "semver_level": "patch"}
+
+
+def tracking_at(days_ago: int) -> dict:
+ timestamp = (NOW - timedelta(days=days_ago)).isoformat()
+ return {"hooks": {UPDATE["repo"]: {"semver_levels": {"patch": timestamp}}}}
+
+
+def test_cooldown_skips_before_boundary_and_allows_at_boundary():
+ config = CooldownConfig(patch=7)
+ eligible, skipped = filter_updates([UPDATE], tracking_at(6), config, now=NOW)
+ assert not eligible
+ assert skipped[0]["days_remaining"] == 1
+
+ eligible, skipped = filter_updates([UPDATE], tracking_at(7), config, now=NOW)
+ assert len(eligible) == 1
+ assert not skipped
+
+
+def test_skip_list_precedes_force_update():
+ eligible, skipped = filter_updates(
+ [UPDATE], {}, CooldownConfig(), now=NOW, force_update=True, skip_hooks=[UPDATE["repo"]]
+ )
+ assert not eligible
+ assert skipped[0]["reason"] == "Hook in skip list"
+
+
+def test_force_update_bypasses_cooldown():
+ eligible, skipped = filter_updates(
+ [UPDATE], tracking_at(0), CooldownConfig(), now=NOW, force_update=True
+ )
+ assert len(eligible) == 1
+ assert not skipped
+
+
+def test_unknown_level_and_invalid_timestamp_are_skipped():
+ unknown = {"repo": "unknown", "semver_level": "unknown"}
+ malformed = {"repo": UPDATE["repo"], "semver_level": "patch"}
+ tracking = {"hooks": {UPDATE["repo"]: {"semver_levels": {"patch": "not-a-date"}}}}
+ eligible, skipped = filter_updates([unknown, malformed], tracking, CooldownConfig(), now=NOW)
+ assert not eligible
+ assert [item["reason"] for item in skipped] == [
+ "Unsupported semantic version level",
+ "Invalid cooldown timestamp",
+ ]
+
+
+def test_now_must_be_timezone_aware():
+ with pytest.raises(ValueError, match="timezone-aware"):
+ filter_updates([UPDATE], {}, CooldownConfig(), now=datetime(2026, 9, 17))
\ No newline at end of file
diff --git a/tests/unit/test_release_info.py b/tests/unit/test_release_info.py
new file mode 100644
index 0000000..3a288c6
--- /dev/null
+++ b/tests/unit/test_release_info.py
@@ -0,0 +1,5 @@
+from precommit_updates.models import is_sha_like_version
+
+
+def test_sha_detection_does_not_depend_on_repository_names():
+ assert is_sha_like_version("0123456789abcdef" * 2 + "01234567")
\ No newline at end of file