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/)