diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml
new file mode 100644
index 0000000..3730230
--- /dev/null
+++ b/.github/workflows/auto-update-precommit-hooks.yml
@@ -0,0 +1,798 @@
+name: Auto-Update Pre-Commit Hooks
+
+on:
+ workflow_call:
+ inputs:
+ cooldown_major_days:
+ description: "Cooldown period for major version updates (days)"
+ required: false
+ default: "28"
+ type: string
+ cooldown_minor_days:
+ description: "Cooldown period for minor version updates (days)"
+ required: false
+ default: "14"
+ type: string
+ cooldown_patch_days:
+ description: "Cooldown period for patch version updates (days)"
+ required: false
+ default: "7"
+ type: string
+ skip_hooks:
+ description: "Comma-separated list of hook repo URLs to skip (e.g., https://github.com/owner/repo)"
+ required: false
+ default: ""
+ type: string
+ force_update:
+ description: "Bypass cooldown periods and update all eligible hooks"
+ required: false
+ default: false
+ type: boolean
+ workflow_dispatch:
+ inputs:
+ cooldown_major_days:
+ description: "Cooldown period for major version updates (days)"
+ required: false
+ default: "28"
+ type: string
+ cooldown_minor_days:
+ description: "Cooldown period for minor version updates (days)"
+ required: false
+ default: "14"
+ type: string
+ cooldown_patch_days:
+ description: "Cooldown period for patch version updates (days)"
+ required: false
+ default: "7"
+ type: string
+ skip_hooks:
+ description: "Comma-separated list of hook repo URLs to skip (e.g., https://github.com/owner/repo)"
+ required: false
+ default: ""
+ type: string
+ force_update:
+ description: "Bypass cooldown periods and update all eligible hooks"
+ required: false
+ default: false
+ type: boolean
+ schedule:
+ # Run weekly on Tuesday at 03:00 UTC
+ - cron: "0 3 * * 2"
+
+concurrency:
+ group: auto-update-precommit-hooks
+ cancel-in-progress: false
+
+# Deny all permissions by default; grant only what's needed per job
+permissions: {}
+
+jobs:
+ detect-updates:
+ name: Detect Available Updates
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ updates_found: ${{ steps.detect.outputs.updates_found }}
+ updates_json: ${{ steps.detect.outputs.updates_json }}
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+
+ - name: Install dependencies
+ run: |
+ pip install pyyaml
+
+ - 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 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}")
+
+ # Only check tagged releases (Dependabot behavior)
+ # Skips repositories without semantic versioning
+ 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]
+
+ # Skip if version hasn't actually changed (SHA may differ but tag is same)
+ if old_version == latest_tag:
+ print(f" ⊘ Skipped: Version unchanged ({old_version})")
+ 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 or '0.0.0', latest_tag),
+ 'commit_range': f'{current_sha}...{latest_sha}'
+ })
+ print(f" Update available: {old_version} -> {latest_tag}")
+ else:
+ # No tagged releases found - skip this repo (Dependabot-aligned behavior)
+ # This ensures we only track semantic versioned hooks
+ print(f" ⊘ Skipped: No tagged releases found (only tagged versions are tracked, like Dependabot)")
+
+ # 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
+
+ apply-cooldown:
+ name: Apply Cooldown Filters
+ needs: detect-updates
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ eligible_updates: ${{ steps.cooldown.outputs.eligible_updates }}
+ skipped_updates: ${{ steps.cooldown.outputs.skipped_updates }}
+ if: needs.detect-updates.outputs.updates_found == 'true'
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+
+ - name: Install dependencies
+ run: |
+ pip install ruamel.yaml
+
+ - name: Apply cooldown filters
+ id: cooldown
+ env:
+ UPDATES_JSON: ${{ needs.detect-updates.outputs.updates_json }}
+ FORCE_UPDATE: ${{ github.event.inputs.force_update || false }}
+ SKIP_HOOKS: ${{ github.event.inputs.skip_hooks || '' }}
+ 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
+
+ fetch-release-info:
+ name: Fetch Release Information
+ needs: apply-cooldown
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ release_info: ${{ steps.release.outputs.release_info }}
+ if: needs.apply-cooldown.outputs.eligible_updates != '[]' && needs.apply-cooldown.outputs.eligible_updates != ''
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+
+ - 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
+
+ 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
+ if: needs.apply-cooldown.outputs.eligible_updates != '[]' && needs.apply-cooldown.outputs.eligible_updates != ''
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+
+ - name: Install dependencies
+ run: |
+ pip install ruamel.yaml
+
+ - 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 subprocess
+ import re
+ from datetime import datetime, timezone
+ from ruamel.yaml import YAML
+
+ # Load release info
+ release_info = json.loads(os.environ['RELEASE_INFO'])
+ skipped = json.loads(os.environ['SKIPPED_UPDATES'] or '[]')
+ force_update = os.environ.get('FORCE_UPDATE', 'false').lower() == 'true'
+
+ 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 comment preservation
+ yaml = YAML()
+ yaml.preserve_quotes = True
+ yaml.default_flow_style = False
+
+ with open('.pre-commit-config.yaml', 'r') as f:
+ config = yaml.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.now(timezone.utc).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 inline comment with frozen version
+ try:
+ from ruamel.yaml.comments import CommentedMap
+ if hasattr(repo_entry, 'ca'):
+ # Update the comment on the 'rev' key with the new version
+ repo_entry.ca.items['rev'] = [None, None, None, f' frozen: {update["new_version"]}']
+ except Exception as e:
+ # Comment update failed - remove the stale comment entirely
+ # Better to have no comment than an incorrect frozen version tag
+ print(f"Warning: Could not update comment for {repo_url}, removing stale comment: {e}")
+ try:
+ if hasattr(repo_entry, 'ca') and 'rev' in repo_entry.ca.items:
+ repo_entry.ca.items['rev'] = [None, None, None, None]
+ except:
+ pass # If we can't even remove it, continue without comment
+
+ # 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.now(timezone.utc).isoformat() + 'Z',
+ 'minor': datetime.now(timezone.utc).isoformat() + 'Z',
+ 'patch': datetime.now(timezone.utc).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.now(timezone.utc).isoformat() + 'Z'
+
+ tracking['hooks'][repo_url]['last_updated'] = datetime.now(timezone.utc).isoformat() + 'Z'
+
+ # Write updated files with comment preservation
+ with open('.pre-commit-config.yaml', 'w') as f:
+ yaml.dump(config, f)
+
+ 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, force_update: bool = False) -> 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 (only show if not overridden by force_update)
+ if force_update:
+ lines.append("### Update Policy")
+ lines.append("")
+ lines.append("> [!NOTE]")
+ lines.append("> **Force Update Override**")
+ lines.append(">")
+ lines.append("> Cooldown periods were bypassed via `force_update: true`. All eligible updates were applied regardless of cooldown state.")
+ lines.append("")
+ else:
+ 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 (only relevant if not force_update)
+ 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, force_update)
+
+ # Create branch and commit
+ branch_name = f"chore/precommit-updates-{datetime.now(timezone.utc).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)
+
+ # Use GitHub CLI to authenticate git for pushing
+ # gh auth setup-git configures git to use gh's stored credentials
+ subprocess.run(['gh', 'auth', 'setup-git'], check=True)
+
+ # Push branch
+ subprocess.run(
+ ['git', 'push', '--force-with-lease', '-u', 'origin', branch_name],
+ check=True
+ )
+
+ # 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
+
+ no-updates:
+ name: No Updates Available
+ needs: detect-updates
+ runs-on: ubuntu-latest
+ permissions: {}
+ if: needs.detect-updates.outputs.updates_found == 'false'
+ steps:
+ - name: Log
+ run: echo "No pre-commit hook updates available at this time."
diff --git a/configs/precommit-update-tracking.json b/configs/precommit-update-tracking.json
new file mode 100644
index 0000000..6cb4bfd
--- /dev/null
+++ b/configs/precommit-update-tracking.json
@@ -0,0 +1,25 @@
+{
+ "last_updated": "2026-09-10T00:00:00Z",
+ "hooks": {
+ "https://github.com/zizmorcore/zizmor-pre-commit": {
+ "last_updated": "2026-09-10T00:00:00Z",
+ "current_sha": "451b56af716f9f0d0c2b816503a3fd0cf8b036fa",
+ "current_version": "v1.29.0",
+ "semver_levels": {
+ "major": "2026-09-10T00:00:00Z",
+ "minor": "2026-09-10T00:00:00Z",
+ "patch": "2026-09-10T00:00:00Z"
+ }
+ },
+ "https://github.com/compilerla/conventional-pre-commit": {
+ "last_updated": "2026-09-10T00:00:00Z",
+ "current_sha": "3db014c16a9d31997ab8c07a4d61fcce936c8f0d",
+ "current_version": "v4.4.0",
+ "semver_levels": {
+ "major": "2026-09-10T00:00:00Z",
+ "minor": "2026-09-10T00:00:00Z",
+ "patch": "2026-09-10T00:00:00Z"
+ }
+ }
+ }
+}
diff --git a/configs/precommit-updates-config.json b/configs/precommit-updates-config.json
new file mode 100644
index 0000000..f00afb4
--- /dev/null
+++ b/configs/precommit-updates-config.json
@@ -0,0 +1,9 @@
+{
+ "cooldown_days": {
+ "major": 28,
+ "minor": 14,
+ "patch": 7
+ },
+ "hooks_to_skip": [],
+ "enable_auto_updates": true
+}
diff --git a/docs/how-to/use-auto-update-precommit-hooks-workflow.md b/docs/how-to/use-auto-update-precommit-hooks-workflow.md
new file mode 100644
index 0000000..edfc16b
--- /dev/null
+++ b/docs/how-to/use-auto-update-precommit-hooks-workflow.md
@@ -0,0 +1,228 @@
+# How to Use the Auto-Update Pre-Commit Hooks Workflow
+
+This guide explains how to use the auto-update pre-commit hooks workflow to keep your pre-commit hooks up-to-date with minimal manual effort.
+
+## Overview
+
+The auto-update workflow:
+- Detects new releases for pre-commit hooks defined in `.pre-commit-config.yaml`
+- Applies semver-based cooldown periods to prevent overly frequent updates
+- Creates informative pull requests with release summaries and risk assessments
+- Can run on a schedule or be triggered manually
+
+## Quick Start
+
+### Automatic Scheduled Updates
+
+The workflow runs automatically every Tuesday at 03:00 UTC. No action needed.
+
+To view scheduled runs, navigate to:
+```
+GitHub Repository → Actions → Auto-Update Pre-Commit Hooks
+```
+
+### Manual Trigger
+
+To manually trigger the workflow:
+
+1. Go to **Actions** tab in your GitHub repository
+2. Select **Auto-Update Pre-Commit Hooks** workflow
+3. Click **Run workflow**
+4. Choose your options (see below) and click **Run workflow**
+
+## Workflow Options
+
+When manually triggering with `workflow_dispatch`, you can customize the behavior:
+
+### Cooldown Periods
+
+The workflow respects semver-based cooldown periods to balance freshness with stability:
+
+| Level | Default Cooldown | Purpose |
+|-------|------------------|---------|
+| **Major** | 28 days | Major updates may introduce breaking changes; longer wait allows time for upstream testing |
+| **Minor** | 14 days | New features; moderate risk of breaking changes |
+| **Patch** | 7 days | Bug fixes; low risk, important for security |
+
+To override cooldown periods in a manual run:
+
+1. In the **Run workflow** dialog:
+ - Set **Cooldown period for major version updates (days)**: e.g., `14` to reduce from default 28
+ - Set **Cooldown period for minor version updates (days)**: e.g., `7`
+ - Set **Cooldown period for patch version updates (days)**: e.g., `3`
+
+2. Click **Run workflow**
+
+### Force Update
+
+To bypass all cooldown periods and update all eligible hooks immediately:
+
+1. In the **Run workflow** dialog, enable **Bypass cooldown periods and update all eligible hooks**
+2. Click **Run workflow**
+
+**⚠️ Use with caution**: Bypassing cooldown periods increases exposure to supply chain attacks.
+
+### Skip Specific Hooks
+
+To exclude specific pre-commit hooks from being updated:
+
+1. In the **Run workflow** dialog, set **Comma-separated list of hook repo URLs to skip**
+ - Example: `https://github.com/zizmorcore/zizmor-pre-commit,https://github.com/other/hook`
+2. Click **Run workflow**
+
+## Understanding the Pull Request
+
+When the workflow detects updates, it creates a pull request with this structure:
+
+### Summary Section
+
+Shows who triggered the update, when it ran, and how many hooks were updated:
+
+```
+alice ran on 2026-09-10 12:34:56 UTC and updated 2 hooks.
+
+Workflow run: [12345](https://github.com/...)
+```
+
+### Changes Section
+
+Lists each updated hook with version information:
+
+```
+### hook-name [MAJOR]
+v1.2.3 → v2.0.0
+
+Commits (5)
+...
+
+
+Release Notes
+...
+
+```
+
+The **Commits** section (collapsible) shows the commits between the old and new versions. Click to expand.
+
+The **Release Notes** section (collapsible) shows the upstream release notes. Click to expand.
+
+### Risks & Notes Section
+
+Highlights potential concerns:
+
+- **Major Version Updates**: Warning if any hook has a major version bump
+- **Short Cooldown Period**: Warning if cooldown < 7 days (increases supply chain attack risk)
+- **Cooldown Periods Applied**: Summary of the cooldown periods used
+- **Skipped Updates**: Lists any updates that were available but skipped due to active cooldown
+
+## Configuration Files
+
+### `configs/precommit-update-tracking.json`
+
+Tracks the last update timestamp and semver levels for each hook. This file is automatically created and updated by the workflow.
+
+**Manual inspection example:**
+
+```json
+{
+ "last_updated": "2026-09-10T12:34:56Z",
+ "hooks": {
+ "https://github.com/zizmorcore/zizmor-pre-commit": {
+ "last_updated": "2026-09-10T12:34:56Z",
+ "current_sha": "abc123...",
+ "current_version": "v1.29.0",
+ "semver_levels": {
+ "major": "2026-07-15T10:00:00Z",
+ "minor": "2026-08-20T14:30:00Z",
+ "patch": "2026-09-08T09:15:00Z"
+ }
+ }
+ }
+}
+```
+
+To manually reset a hook's cooldown, edit `semver_levels.{level}` to an earlier date.
+
+### `configs/precommit-updates-config.json`
+
+Stores default configuration for the workflow:
+
+```json
+{
+ "cooldown_days": {
+ "major": 28,
+ "minor": 14,
+ "patch": 7
+ },
+ "hooks_to_skip": [],
+ "enable_auto_updates": true
+}
+```
+
+**Note**: Workflow input parameters take precedence over this file.
+
+## Reviewing and Merging Updates
+
+1. When a PR is created by the workflow, review the changes:
+ - Inspect the release notes (click to expand)
+ - Check the commit history (click to expand)
+ - Note any warnings in the **Risks & Notes** section
+
+2. For major version updates:
+ - Read the release notes carefully
+ - Check for any breaking changes
+ - Test locally if concerned: `pre-commit run --all-files`
+
+3. Merge the PR once satisfied with the changes
+
+## Troubleshooting
+
+### Workflow Run Shows "No Updates Found"
+
+This means all pre-commit hooks are on their latest versions, or all available updates are still in cooldown periods. This is expected behavior.
+
+### A Specific Hook Never Updates
+
+Possible reasons:
+1. **Cooldown period active**: Check `configs/precommit-update-tracking.json` to see when the last update occurred
+2. **Hook is in skip list**: Check `skip_hooks` input or `configs/precommit-updates-config.json`
+3. **No releases available**: The upstream repository may not use GitHub releases. The workflow falls back to checking commits.
+
+To force an update:
+- Manually trigger the workflow with **Bypass cooldown periods and update all eligible hooks** enabled
+
+### Workflow Fails with "Could Not Fetch Latest Release"
+
+The workflow falls back to fetching the latest commit if GitHub releases are unavailable. This is normal and the workflow should still complete successfully.
+
+## Advanced: Manual Configuration
+
+### Temporarily Skip a Hook
+
+Edit `configs/precommit-updates-config.json` and add the hook URL to `hooks_to_skip`:
+
+```json
+{
+ "hooks_to_skip": ["https://github.com/zizmorcore/zizmor-pre-commit"]
+}
+```
+
+Then commit and push. The next workflow run will skip that hook.
+
+### Reset Cooldown for a Single Hook
+
+Edit `configs/precommit-update-tracking.json` and set the desired `semver_levels.{level}` to an old date (e.g., `2020-01-01T00:00:00Z`):
+
+```json
+"semver_levels": {
+ "major": "2020-01-01T00:00:00Z",
+ "minor": "2026-09-10T12:34:56Z",
+ "patch": "2026-09-10T12:34:56Z"
+}
+```
+
+Commit and push, then trigger the workflow.
+
+## See Also
+
+- [Auto-Update Pre-Commit Hooks Reference](../reference/auto-update-precommit-hooks.md) — detailed input/output contract
+- [.pre-commit-config.yaml](.pre-commit-config.yaml) — your repository's pre-commit hooks
diff --git a/docs/reference/auto-update-precommit-hooks.md b/docs/reference/auto-update-precommit-hooks.md
new file mode 100644
index 0000000..6673729
--- /dev/null
+++ b/docs/reference/auto-update-precommit-hooks.md
@@ -0,0 +1,386 @@
+# Auto-Update Pre-Commit Hooks Reference
+
+Complete reference for the auto-update pre-commit hooks workflow.
+
+## Workflow Metadata
+
+| Property | Value |
+|----------|-------|
+| **Workflow file** | `.github/workflows/auto-update-precommit-hooks.yml` |
+| **Type** | Standalone workflow (not reusable) |
+| **Triggers** | `workflow_dispatch` (manual), `schedule` (weekly Tuesday 03:00 UTC) |
+| **Permissions** | `contents: write`, `pull-requests: write` |
+
+## Inputs (workflow_dispatch only)
+
+### `cooldown_major_days`
+
+**Description**: Cooldown period (in days) before updating pre-commit hooks to a new major version.
+
+**Type**: `string`
+**Default**: `"28"`
+**Required**: No
+**Example**: `"14"` to reduce from 28 to 14 days
+
+**Rationale**: Major version updates may introduce breaking changes. A longer cooldown period allows time for upstream testing and community feedback before adoption.
+
+---
+
+### `cooldown_minor_days`
+
+**Description**: Cooldown period (in days) before updating pre-commit hooks to a new minor version.
+
+**Type**: `string`
+**Default**: `"14"`
+**Required**: No
+**Example**: `"7"` to reduce from 14 to 7 days
+
+**Rationale**: Minor versions introduce new features and may have minor breaking changes. A moderate cooldown balances freshness with stability.
+
+---
+
+### `cooldown_patch_days`
+
+**Description**: Cooldown period (in days) before updating pre-commit hooks to a new patch version.
+
+**Type**: `string`
+**Default**: `"7"`
+**Required**: No
+**Example**: `"3"` to reduce from 7 to 3 days
+
+**Rationale**: Patch versions are bug fixes and security updates. A shorter cooldown is acceptable for patches, but 7 days is recommended for supply chain attack mitigation.
+
+---
+
+### `skip_hooks`
+
+**Description**: Comma-separated list of pre-commit hook repository URLs to skip during this run.
+
+**Type**: `string`
+**Default**: `""` (empty string; no hooks skipped)
+**Required**: No
+**Example**: `"https://github.com/zizmorcore/zizmor-pre-commit,https://github.com/other/hook"`
+
+**Rationale**: Allows temporary exclusion of specific hooks from auto-updates without modifying configuration files.
+
+---
+
+### `force_update`
+
+**Description**: Bypass all cooldown periods and update all eligible hooks immediately.
+
+**Type**: `boolean`
+**Default**: `false`
+**Required**: No
+**Example**: `true`
+
+**⚠️ Security Note**: Enabling this increases exposure to supply chain attacks by bypassing the recommended cooldown periods. Use only when necessary and with careful review.
+
+---
+
+## Job Outputs
+
+### `detect-updates`
+
+**`updates_found`**
+- Type: `string` (`"true"` or `"false"`)
+- Description: Whether any updates were detected in the configured pre-commit hooks
+
+**`updates_json`**
+- Type: `string` (JSON-encoded array)
+- Description: Array of detected updates with structure:
+ ```json
+ [
+ {
+ "repo": "https://github.com/zizmorcore/zizmor-pre-commit",
+ "old_sha": "451b56af716f9f0d0c2b816503a3fd0cf8b036fa",
+ "new_sha": "abc123def456...",
+ "old_version": "v1.29.0",
+ "new_version": "v1.30.0",
+ "semver_level": "minor",
+ "commit_range": "451b56af716f9f0d0c2b816503a3fd0cf8b036fa...abc123def456"
+ }
+ ]
+ ```
+
+---
+
+### `apply-cooldown`
+
+**`eligible_updates`**
+- Type: `string` (JSON-encoded array)
+- Description: Updates that passed the cooldown filter, same structure as `detect-updates.updates_json` plus `cooldown_applied` field
+
+**`skipped_updates`**
+- Type: `string` (JSON-encoded array)
+- Description: Updates that were filtered out due to active cooldown, with additional fields:
+ - `reason`: Human-readable reason (e.g., "Cooldown active: 3/28 days")
+ - `days_remaining`: Number of days until cooldown expires
+
+---
+
+### `fetch-release-info`
+
+**`release_info`**
+- Type: `string` (JSON-encoded array)
+- Description: Eligible updates enriched with release notes and commit information:
+ ```json
+ [
+ {
+ "repo": "https://github.com/zizmorcore/zizmor-pre-commit",
+ "old_sha": "451b56af716f9f0d0c2b816503a3fd0cf8b036fa",
+ "new_sha": "abc123def456...",
+ "old_version": "v1.29.0",
+ "new_version": "v1.30.0",
+ "semver_level": "minor",
+ "commit_range": "451b56af716f9f0d0c2b816503a3fd0cf8b036fa...abc123def456",
+ "release_notes": "## v1.30.0\n\n### Features\n- Added feature X\n\n### Fixes\n- Fixed bug Y",
+ "commits": ["abc123def456...", "def456ghi789..."],
+ "commit_count": 2
+ }
+ ]
+ ```
+
+---
+
+## Configuration Files
+
+### `configs/precommit-update-tracking.json`
+
+**Schema**:
+
+```json
+{
+ "last_updated": "2026-09-10T12:34:56Z",
+ "hooks": {
+ "{repo_url}": {
+ "last_updated": "2026-09-10T12:34:56Z",
+ "current_sha": "{40-char-sha}",
+ "current_version": "{semantic-version-tag}",
+ "semver_levels": {
+ "major": "2026-07-15T10:00:00Z",
+ "minor": "2026-08-20T14:30:00Z",
+ "patch": "2026-09-08T09:15:00Z"
+ }
+ }
+ }
+}
+```
+
+**Field Descriptions**:
+
+- `last_updated`: ISO-8601 timestamp of the last workflow update to this file
+- `hooks[{repo_url}].last_updated`: Last time this specific hook was updated
+- `hooks[{repo_url}].current_sha`: Current commit SHA for this hook (40 characters)
+- `hooks[{repo_url}].current_version`: Semantic version tag (e.g., `v1.29.0`)
+- `hooks[{repo_url}].semver_levels.{level}`: ISO-8601 timestamp of the last update at this semver level
+ - `major`: Last major version update
+ - `minor`: Last minor version update
+ - `patch`: Last patch version update
+
+**Initialization**: The workflow initializes this file on first run using hooks from `.pre-commit-config.yaml`.
+
+**Persistence**: Updated automatically by the workflow after creating a PR.
+
+---
+
+### `configs/precommit-updates-config.json`
+
+**Schema**:
+
+```json
+{
+ "cooldown_days": {
+ "major": 28,
+ "minor": 14,
+ "patch": 7
+ },
+ "hooks_to_skip": [],
+ "enable_auto_updates": true
+}
+```
+
+**Field Descriptions**:
+
+- `cooldown_days.major`: Default cooldown period for major version updates (days)
+- `cooldown_days.minor`: Default cooldown period for minor version updates (days)
+- `cooldown_days.patch`: Default cooldown period for patch version updates (days)
+- `hooks_to_skip`: Array of hook repository URLs to exclude from auto-updates
+- `enable_auto_updates`: Global flag to enable/disable auto-updates (currently informational; not enforced by workflow)
+
+**Precedence**: Workflow input parameters override these defaults when provided.
+
+**Manual editing**: You can edit this file directly to change defaults or add hooks to the skip list.
+
+---
+
+## Pull Request Body Format
+
+The workflow generates pull requests with this markdown structure:
+
+```markdown
+## Summary
+
+{github.actor} ran on {YYYY-MM-DD HH:MM:SS UTC} and updated {N} hook(s).
+
+**Workflow run**: [{RUN_ID}]({RUN_URL})
+
+---
+
+## Changes
+
+### {hook_name} `[{SEMVER_LEVEL}]`
+`{old_version}` → `{new_version}`
+
+Commits ({count})
+
+\`\`\`
+- {commit_sha_short}
+...
+\`\`\`
+
+[View commit history]({repo_url}/compare/{old_sha_short}...{new_sha_short})
+
+
+
+Release Notes
+
+{release_notes_text}
+
+
+
+---
+
+## Risks & Notes
+
+### ⚠️ Major Version Updates (if applicable)
+Major versions may introduce breaking changes...
+
+### ⚠️ Short Cooldown Period (if applicable)
+Cooldown periods less than 7 days increase vulnerability...
+
+### Cooldown Periods Applied
+- **Major versions**: {N} days
+- **Minor versions**: {N} days
+- **Patch versions**: {N} days
+
+### Skipped Updates (if any)
+The following updates are available but skipped...
+```
+
+---
+
+## Behavior by Event
+
+### `workflow_dispatch` (Manual Trigger)
+
+1. Detects available updates
+2. Applies cooldown filters (respecting input overrides)
+3. Fetches release notes and commit history
+4. Updates `.pre-commit-config.yaml` and `precommit-update-tracking.json`
+5. Creates a PR on the `main` branch from a feature branch
+
+**Branch naming**: `chore/precommit-updates-{YYYYMMDD}`
+
+**PR title**: `chore(pre-commit): auto-update hooks ({N} update(s))`
+
+**Commit author**: `github-actions[bot]` (41898282+github-actions[bot]@users.noreply.github.com)
+
+---
+
+### `schedule` (Automatic Weekly Run)
+
+Runs at **Tuesday 03:00 UTC** using default cooldown periods.
+
+- Input: `cooldown_major_days=28`, `cooldown_minor_days=14`, `cooldown_patch_days=7`
+- Actor: Workflow (no explicit user)
+- Honors all skip lists and configurations
+
+---
+
+## Semver Level Detection
+
+The workflow determines update severity using semantic versioning:
+
+| From | To | Level | Default Cooldown |
+|------|----|----|------------------|
+| v1.2.3 | v2.0.0 | **MAJOR** | 28 days |
+| v1.2.3 | v1.3.0 | **MINOR** | 14 days |
+| v1.2.3 | v1.2.4 | **PATCH** | 7 days |
+| {sha} | {new_sha} | **PATCH** | 7 days (if no tag) |
+
+If the workflow cannot parse a version (e.g., no release tag), it defaults to `PATCH` level.
+
+---
+
+## Error Handling
+
+| Scenario | Behavior |
+|----------|----------|
+| No updates found | Job `no-updates` runs; logs message and exits successfully |
+| GitHub API limit exceeded | Workflow logs warning and continues with fallback data |
+| Release notes unavailable | Logs "(No release notes available)"; PR still created |
+| Commit history fetch fails | Uses short commit SHAs; PR still created |
+| PR creation fails | Workflow fails with error message; manual PR creation may be needed |
+| Concurrent runs (race condition) | Later run may have git push conflicts; manual intervention needed |
+
+---
+
+## Permissions Required
+
+| Permission | Scope | Why |
+|-----------|-------|-----|
+| `contents: write` | Repository | To commit and push updates to `.pre-commit-config.yaml` |
+| `pull-requests: write` | Repository | To create pull requests |
+| `GITHUB_TOKEN` | API | To fetch release information and commit history (automatic) |
+
+---
+
+## Frequently Asked Questions
+
+### Q: Why does the workflow show "No updates found" even though I see a new release?
+
+**A**: Possible reasons:
+1. The new release was published less than the configured cooldown period ago
+2. The hook is in the `hooks_to_skip` list
+3. The upstream repository doesn't publish releases; check if commit history is being used instead
+
+Check `configs/precommit-update-tracking.json` for the last update timestamp per semver level.
+
+---
+
+### Q: Can I manually edit `precommit-update-tracking.json` to reset cooldowns?
+
+**A**: Yes. Edit the `semver_levels.{level}` timestamp to an earlier date to make updates available immediately. For example, set `"major": "2020-01-01T00:00:00Z"` to reset the major version cooldown.
+
+---
+
+### Q: What if I want to skip a hook permanently?
+
+**A**: Add the hook URL to `configs/precommit-updates-config.json`:
+
+```json
+"hooks_to_skip": ["https://github.com/zizmorcore/zizmor-pre-commit"]
+```
+
+Or pass `skip_hooks` during manual trigger.
+
+---
+
+### Q: What if a PR is created but the branch push fails?
+
+**A**: Check for:
+1. Branch protection rules that block pushes
+2. Concurrent workflow runs (race condition)
+3. Repository access permissions
+
+Manual push of the branch and PR creation may be needed.
+
+---
+
+## See Also
+
+- [How to Use the Auto-Update Workflow](../how-to/use-auto-update-precommit-hooks-workflow.md)
+- [.pre-commit-config.yaml](../../.pre-commit-config.yaml) — Your repository's hooks
+- [Pre-commit Documentation](https://pre-commit.com/)
+- [Conventional Commits](https://www.conventionalcommits.org/)