From 4a9325381a84dcb7d1cd3b9840c5fcad2a76218b Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:01:39 +0100 Subject: [PATCH 01/25] feat: add auto-update workflow for pre-commit hooks - Introduced `configs/precommit-update-tracking.json` to track hook updates and semver levels. - Added `configs/precommit-updates-config.json` for workflow configuration, including cooldown periods and hooks to skip. - Created documentation on how to use the auto-update workflow, detailing manual and scheduled triggers, cooldown periods, and PR structure. - Developed a comprehensive reference for the auto-update workflow, outlining inputs, job outputs, and error handling. --- .../workflows/auto-update-precommit-hooks.yml | 698 ++++++++++++++++++ configs/precommit-update-tracking.json | 25 + configs/precommit-updates-config.json | 9 + ...se-auto-update-precommit-hooks-workflow.md | 228 ++++++ docs/reference/auto-update-precommit-hooks.md | 386 ++++++++++ 5 files changed, 1346 insertions(+) create mode 100644 .github/workflows/auto-update-precommit-hooks.yml create mode 100644 configs/precommit-update-tracking.json create mode 100644 configs/precommit-updates-config.json create mode 100644 docs/how-to/use-auto-update-precommit-hooks-workflow.md create mode 100644 docs/reference/auto-update-precommit-hooks.md diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml new file mode 100644 index 0000000..74aa96d --- /dev/null +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -0,0 +1,698 @@ +name: Auto-Update Pre-Commit Hooks + +on: + 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@9bb56186c3b09b1be11b23adc9f22067da19db5c # v4.1.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + with: + python-version: "3.11" + + - 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 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() + + # Try to get the latest release + cmd = [ + 'gh', 'api', '--paginate', + f'repos/{owner}/{repo}/releases', + '-q', '.[0] | {tag: .tag_name, sha: .target_commitish}' + ] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode == 0 and result.stdout.strip(): + data = json.loads(result.stdout) + if data.get('tag') and data.get('sha'): + return (data['tag'], data['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 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 = [] + + 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: + # Try to determine old version from config comments if available + hooks_section = repo_entry.get('hooks', []) + old_version = None + + # Look for version comment in YAML (may not be easily accessible) + # For now, we'll extract from tag + 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 + + 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@9bb56186c3b09b1be11b23adc9f22067da19db5c # v4.1.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + with: + python-version: "3.11" + + - 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 + + # 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.utcnow() + 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@9bb56186c3b09b1be11b23adc9f22067da19db5c # v4.1.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.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@9bb56186c3b09b1be11b23adc9f22067da19db5c # v4.1.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install pyyaml + + - 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 }} + 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 + + # 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) + + # 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) + + def generate_pr_body(updates: list, skipped: list, cooldown_config: dict) -> str: + """Generate comprehensive PR body.""" + now = datetime.utcnow() + + 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("### ⚠️ 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("### ⚠️ 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) + + 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 + 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..08bdde5 --- /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 Monday at 09: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: + +```json +"semver_levels": { + "major": "2020-01-01T00:00:00Z", // Reset to old date + "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..2e0fb22 --- /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 Monday 09: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 **Monday 09: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/) From 7298ea1a1fcce0110785c339bb7b745801542698 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:04:36 +0100 Subject: [PATCH 02/25] feat: enhance workflow_call inputs with cooldown and skip options for pre-commit hooks --- .../workflows/auto-update-precommit-hooks.yml | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 74aa96d..df927dd 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -1,6 +1,33 @@ 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: From d976f329d3908b5764f35c0d0dd5d98ab89e2949 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:09:03 +0100 Subject: [PATCH 03/25] chore: update actions/checkout to version 7.0.1 for improved functionality --- .github/workflows/auto-update-precommit-hooks.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index df927dd..bbc064f 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -77,7 +77,7 @@ jobs: updates_json: ${{ steps.detect.outputs.updates_json }} steps: - name: Check out repository - uses: actions/checkout@9bb56186c3b09b1be11b23adc9f22067da19db5c # v4.1.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -252,7 +252,7 @@ jobs: if: needs.detect-updates.outputs.updates_found == 'true' steps: - name: Check out repository - uses: actions/checkout@9bb56186c3b09b1be11b23adc9f22067da19db5c # v4.1.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -357,7 +357,7 @@ jobs: if: needs.apply-cooldown.outputs.eligible_updates != '[]' && needs.apply-cooldown.outputs.eligible_updates != '' steps: - name: Check out repository - uses: actions/checkout@9bb56186c3b09b1be11b23adc9f22067da19db5c # v4.1.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -474,7 +474,7 @@ jobs: if: needs.apply-cooldown.outputs.eligible_updates != '[]' && needs.apply-cooldown.outputs.eligible_updates != '' steps: - name: Check out repository - uses: actions/checkout@9bb56186c3b09b1be11b23adc9f22067da19db5c # v4.1.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false From 44fa3cee48d27c472e7f2cef45763104abf0090f Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:10:41 +0100 Subject: [PATCH 04/25] feat: add installation step for pyyaml dependency in auto-update workflow --- .github/workflows/auto-update-precommit-hooks.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index bbc064f..8d20fb0 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -87,6 +87,10 @@ jobs: with: python-version: "3.11" + - name: Install dependencies + run: | + pip install pyyaml + - name: Detect available updates id: detect env: @@ -261,6 +265,10 @@ jobs: with: python-version: "3.11" + - name: Install dependencies + run: | + pip install pyyaml + - name: Apply cooldown filters id: cooldown env: From ed745d2344037d971c9db4ea4d54d3761abd986c Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:12:05 +0100 Subject: [PATCH 05/25] feat: enhance get_latest_release function to resolve tag to immutable commit SHA --- .../workflows/auto-update-precommit-hooks.yml | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 8d20fb0..df03001 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -105,7 +105,7 @@ jobs: from typing import Optional, Tuple, Dict, Any def get_latest_release(repo_url: str) -> Optional[Tuple[str, str]]: - """Fetch latest release tag and SHA from GitHub API.""" + """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) @@ -115,18 +115,28 @@ jobs: owner, repo = match.groups() - # Try to get the latest release + # Get the latest release tag (immutable) cmd = [ 'gh', 'api', '--paginate', f'repos/{owner}/{repo}/releases', - '-q', '.[0] | {tag: .tag_name, sha: .target_commitish}' + '-q', '.[0].tag_name' ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if result.returncode == 0 and result.stdout.strip(): - data = json.loads(result.stdout) - if data.get('tag') and data.get('sha'): - return (data['tag'], data['sha']) + 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}") From d8e02c0c411e125f6de42823d78e3758f013ed69 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:15:02 +0100 Subject: [PATCH 06/25] feat: add function to resolve commit SHA to tag and enhance version tracking for pre-commit hooks --- .../workflows/auto-update-precommit-hooks.yml | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index df03001..89c920a 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -165,6 +165,33 @@ jobs: 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) @@ -192,6 +219,13 @@ jobs: 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') @@ -208,13 +242,20 @@ jobs: if release_info: latest_tag, latest_sha = release_info if latest_sha != current_sha: - # Try to determine old version from config comments if available - hooks_section = repo_entry.get('hooks', []) + # Determine old version from tracking file or by resolving the SHA old_version = None - # Look for version comment in YAML (may not be easily accessible) - # For now, we'll extract from tag - old_version = current_sha[:7] + # 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, From cef05d51568da0be364071ff252d8722872d7758 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:18:26 +0100 Subject: [PATCH 07/25] feat: refactor PR generation logic and enhance warning messages for pre-commit updates --- .../workflows/auto-update-precommit-hooks.yml | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 89c920a..588418f 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -627,41 +627,13 @@ jobs: with open('configs/precommit-update-tracking.json', 'w') as f: json.dump(tracking, f, indent=2) - # 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) + # 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.""" @@ -731,17 +703,19 @@ jobs: # Check for major updates has_major = any(u['semver_level'] == 'major' for u in updates) if has_major: - 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("> [!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("### ⚠️ 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("> [!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 @@ -765,12 +739,41 @@ jobs: return "\n".join(lines) - 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 + # 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 no-updates: From 6473117b07324c32626ce600a1208a4e4a24f317 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:24:38 +0100 Subject: [PATCH 08/25] fix: update cron schedule to run weekly on Monday at 09:00 UTC and adjust datetime handling to use timezone-aware objects --- .github/workflows/auto-update-precommit-hooks.yml | 8 ++++---- docs/how-to/use-auto-update-precommit-hooks-workflow.md | 2 +- docs/reference/auto-update-precommit-hooks.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 588418f..0863d4a 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -333,7 +333,7 @@ jobs: python3 << 'EOF' import json import os - from datetime import datetime, timedelta + from datetime import datetime, timedelta, timezone # Load updates updates = json.loads(os.environ['UPDATES_JSON']) @@ -354,7 +354,7 @@ jobs: 'patch': int(os.environ['COOLDOWN_PATCH']) } - now = datetime.utcnow() + now = datetime.now(timezone.utc) eligible = [] skipped = [] @@ -562,7 +562,7 @@ jobs: import yaml import subprocess import re - from datetime import datetime + from datetime import datetime, timezone # Load release info release_info = json.loads(os.environ['RELEASE_INFO']) @@ -637,7 +637,7 @@ jobs: def generate_pr_body(updates: list, skipped: list, cooldown_config: dict) -> str: """Generate comprehensive PR body.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc) lines = [] lines.append("## Summary") diff --git a/docs/how-to/use-auto-update-precommit-hooks-workflow.md b/docs/how-to/use-auto-update-precommit-hooks-workflow.md index 08bdde5..d317fe3 100644 --- a/docs/how-to/use-auto-update-precommit-hooks-workflow.md +++ b/docs/how-to/use-auto-update-precommit-hooks-workflow.md @@ -14,7 +14,7 @@ The auto-update workflow: ### Automatic Scheduled Updates -The workflow runs automatically every Monday at 09:00 UTC. No action needed. +The workflow runs automatically every Tuesday at 03:00 UTC. No action needed. To view scheduled runs, navigate to: ``` diff --git a/docs/reference/auto-update-precommit-hooks.md b/docs/reference/auto-update-precommit-hooks.md index 2e0fb22..6673729 100644 --- a/docs/reference/auto-update-precommit-hooks.md +++ b/docs/reference/auto-update-precommit-hooks.md @@ -8,7 +8,7 @@ Complete reference for the auto-update pre-commit hooks workflow. |----------|-------| | **Workflow file** | `.github/workflows/auto-update-precommit-hooks.yml` | | **Type** | Standalone workflow (not reusable) | -| **Triggers** | `workflow_dispatch` (manual), `schedule` (weekly Monday 09:00 UTC) | +| **Triggers** | `workflow_dispatch` (manual), `schedule` (weekly Tuesday 03:00 UTC) | | **Permissions** | `contents: write`, `pull-requests: write` | ## Inputs (workflow_dispatch only) @@ -290,7 +290,7 @@ The following updates are available but skipped... ### `schedule` (Automatic Weekly Run) -Runs at **Monday 09:00 UTC** using default cooldown periods. +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) From 9281ef9b873f483bc734ec564ea958f343ff1eec Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:26:03 +0100 Subject: [PATCH 09/25] docs: clarify instructions for resetting cooldown in pre-commit update tracking --- docs/how-to/use-auto-update-precommit-hooks-workflow.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/how-to/use-auto-update-precommit-hooks-workflow.md b/docs/how-to/use-auto-update-precommit-hooks-workflow.md index d317fe3..edfc16b 100644 --- a/docs/how-to/use-auto-update-precommit-hooks-workflow.md +++ b/docs/how-to/use-auto-update-precommit-hooks-workflow.md @@ -210,11 +210,11 @@ 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: +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", // Reset to old date + "major": "2020-01-01T00:00:00Z", "minor": "2026-09-10T12:34:56Z", "patch": "2026-09-10T12:34:56Z" } From a630d4e210e7001f18a408d622c744d8f8879540 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:31:35 +0100 Subject: [PATCH 10/25] feat: configure git to use GITHUB_TOKEN for authentication in update process --- .../workflows/auto-update-precommit-hooks.yml | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 0863d4a..b6724ed 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -588,7 +588,7 @@ jobs: with open('configs/precommit-update-tracking.json', 'r') as f: tracking = json.load(f) except FileNotFoundError: - tracking = {'last_updated': datetime.utcnow().isoformat() + 'Z', 'hooks': {}} + 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} @@ -606,9 +606,9 @@ jobs: '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' + 'major': datetime.now(timezone.utc).isoformat() + 'Z', + 'minor': datetime.now(timezone.utc).isoformat() + 'Z', + 'patch': datetime.now(timezone.utc).isoformat() + 'Z' } } else: @@ -616,9 +616,9 @@ jobs: 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]['semver_levels'][semver] = datetime.now(timezone.utc).isoformat() + 'Z' - tracking['hooks'][repo_url]['last_updated'] = datetime.utcnow().isoformat() + 'Z' + tracking['hooks'][repo_url]['last_updated'] = datetime.now(timezone.utc).isoformat() + 'Z' # Write updated files with open('.pre-commit-config.yaml', 'w') as f: @@ -743,18 +743,26 @@ jobs: 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')}" + 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) + + # Configure git to use GITHUB_TOKEN for authentication (secure alternative to persist-credentials) + github_token = os.environ.get('GITHUB_TOKEN', '') + subprocess.run( + ['git', 'config', '--global', 'url.https://x-access-token:' + github_token + '@github.com/.insteadOf', 'https://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'}) + # Push branch (git authentication already configured via credential helper) + subprocess.run(['git', 'push', '-u', 'origin', branch_name], check=True) # Create PR using GitHub CLI pr_result = subprocess.run( From 7436743120bd5fe5e0d559f454ce509fe76bb9c9 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:42:41 +0100 Subject: [PATCH 11/25] feat: update git push command to use --force-with-lease for safer branch updates --- .github/workflows/auto-update-precommit-hooks.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index b6724ed..325cf5f 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -761,8 +761,9 @@ jobs: 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 (git authentication already configured via credential helper) - subprocess.run(['git', 'push', '-u', 'origin', branch_name], check=True) + # Push branch with force-with-lease to handle existing remote branch safely + # (useful when multiple runs occur on the same day during testing) + subprocess.run(['git', 'push', '--force-with-lease', '-u', 'origin', branch_name], check=True) # Create PR using GitHub CLI pr_result = subprocess.run( From 08da0d1a9d345af5076d62d25dedfc169cb73990 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:45:17 +0100 Subject: [PATCH 12/25] feat: update setup-python action to version 7.0.0 for improved functionality --- .github/workflows/auto-update-precommit-hooks.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 325cf5f..3a87c2a 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -83,7 +83,7 @@ jobs: 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" @@ -312,7 +312,7 @@ jobs: 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" @@ -421,7 +421,7 @@ jobs: 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" @@ -539,7 +539,7 @@ jobs: 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" From c2c03f5f4fac430be452c6c1228da0fe7b0eac0a Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:46:10 +0100 Subject: [PATCH 13/25] feat: update git configuration to use GITHUB_TOKEN via HTTP header for improved reliability --- .github/workflows/auto-update-precommit-hooks.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 3a87c2a..3d9c5b4 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -748,10 +748,11 @@ jobs: 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) - # Configure git to use GITHUB_TOKEN for authentication (secure alternative to persist-credentials) + # Configure git to use GITHUB_TOKEN for authentication via HTTP header + # This is more reliable than URL rewriting in GitHub Actions environments github_token = os.environ.get('GITHUB_TOKEN', '') subprocess.run( - ['git', 'config', '--global', 'url.https://x-access-token:' + github_token + '@github.com/.insteadOf', 'https://github.com/'], + ['git', 'config', '--global', 'http.extraheader', f'Authorization: token {github_token}'], check=True ) From 60e4e3c17c5351d547f3cd840504c925e9a9cd94 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 10:49:23 +0100 Subject: [PATCH 14/25] feat: update git push command to use token in URL for improved security --- .../workflows/auto-update-precommit-hooks.yml | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 3d9c5b4..1811d85 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -747,24 +747,22 @@ jobs: 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) - - # Configure git to use GITHUB_TOKEN for authentication via HTTP header - # This is more reliable than URL rewriting in GitHub Actions environments - github_token = os.environ.get('GITHUB_TOKEN', '') - subprocess.run( - ['git', 'config', '--global', 'http.extraheader', f'Authorization: token {github_token}'], - 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 with force-with-lease to handle existing remote branch safely - # (useful when multiple runs occur on the same day during testing) - subprocess.run(['git', 'push', '--force-with-lease', '-u', 'origin', branch_name], check=True) + # Push branch using token in URL (temporary, safe because token is already exposed via env) + # Get repository info from environment + repo = os.environ.get('GITHUB_REPOSITORY', '') + github_token = os.environ.get('GITHUB_TOKEN', '') + remote_url = f'https://x-access-token:{github_token}@github.com/{repo}.git' + subprocess.run( + ['git', 'push', '--force-with-lease', '-u', remote_url, branch_name], + check=True, + env={**os.environ, 'GIT_TRACE': '0'} # Disable tracing to avoid logging token + ) # Create PR using GitHub CLI pr_result = subprocess.run( From eb4e22bfe3950eabd17866b5c6ec021dc23b8608 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 11:51:34 +0100 Subject: [PATCH 15/25] feat: update push command to use GitHub CLI for authentication and simplify remote URL handling --- .github/workflows/auto-update-precommit-hooks.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 1811d85..c79ed39 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -753,15 +753,14 @@ jobs: 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 using token in URL (temporary, safe because token is already exposed via env) - # Get repository info from environment - repo = os.environ.get('GITHUB_REPOSITORY', '') - github_token = os.environ.get('GITHUB_TOKEN', '') - remote_url = f'https://x-access-token:{github_token}@github.com/{repo}.git' + # 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', remote_url, branch_name], - check=True, - env={**os.environ, 'GIT_TRACE': '0'} # Disable tracing to avoid logging token + ['git', 'push', '--force-with-lease', '-u', 'origin', branch_name], + check=True ) # Create PR using GitHub CLI From 05385b3cc7ee00eb76288912fa99ab6e3a82e0e3 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 11:59:23 +0100 Subject: [PATCH 16/25] feat: refine update detection to only track semantic versioned releases and skip repos without tagged releases --- .../workflows/auto-update-precommit-hooks.yml | 42 +++---------------- 1 file changed, 5 insertions(+), 37 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index c79ed39..fc12291 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -142,29 +142,6 @@ jobs: 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: @@ -236,7 +213,8 @@ jobs: print(f"Checking updates for: {repo_url}") - # Try to get latest release first + # Only check tagged releases (Dependabot behavior) + # Skips repositories without semantic versioning release_info = get_latest_release(repo_url) if release_info: @@ -268,19 +246,9 @@ jobs: }) 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}' - }) + # 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: From a7a66e84b1a62ffc36320a969a02e53aba915d94 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:06:05 +0100 Subject: [PATCH 17/25] feat: add force update option to bypass cooldown periods in PR generation --- .../workflows/auto-update-precommit-hooks.yml | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index fc12291..866baa3 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -520,6 +520,7 @@ jobs: 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' }} @@ -535,6 +536,7 @@ jobs: # 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']), @@ -603,7 +605,7 @@ jobs: return match.group(1) return repo_url - def generate_pr_body(updates: list, skipped: list, cooldown_config: dict) -> str: + 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) @@ -686,15 +688,24 @@ jobs: 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("") + # 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 + # Skipped updates (only relevant if not force_update) if skipped: lines.append("### Skipped Updates") lines.append("") @@ -708,7 +719,7 @@ jobs: return "\n".join(lines) # Generate PR body - pr_body = generate_pr_body(release_info, skipped, cooldown_config) + 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')}" From 8a6116b2f40e548f8cf8bdcee7c59ec5426c1f90 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:08:09 +0100 Subject: [PATCH 18/25] feat: replace pyyaml with ruamel.yaml for improved YAML handling and comment preservation --- .../workflows/auto-update-precommit-hooks.yml | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 866baa3..ccffc0a 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -89,7 +89,7 @@ jobs: - name: Install dependencies run: | - pip install pyyaml + pip install ruamel.yaml - name: Detect available updates id: detect @@ -97,7 +97,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | python3 << 'EOF' - import yaml + from ruamel.yaml import YAML import json import subprocess import os @@ -286,7 +286,7 @@ jobs: - name: Install dependencies run: | - pip install pyyaml + pip install ruamel.yaml - name: Apply cooldown filters id: cooldown @@ -513,7 +513,7 @@ jobs: - name: Install dependencies run: | - pip install pyyaml + pip install ruamel.yaml - name: Update configs and create PR env: @@ -528,10 +528,10 @@ jobs: python3 << 'EOF' import json import os - import yaml 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']) @@ -549,9 +549,13 @@ jobs: print("No updates to apply") exit(0) - # Load and update .pre-commit-config.yaml + # 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.safe_load(f) + config = yaml.load(f) # Update tracking file try: @@ -570,6 +574,10 @@ jobs: update = update_map[repo_url] repo_entry['rev'] = update['new_sha'] + # Add version as inline comment (e.g., # frozen: v1.2.3) + if hasattr(repo_entry, 'ca'): + repo_entry.ca.comment[None] = [None, f' frozen: {update["new_version"]}'] + # Update tracking if repo_url not in tracking['hooks']: tracking['hooks'][repo_url] = { @@ -590,9 +598,9 @@ jobs: tracking['hooks'][repo_url]['last_updated'] = datetime.now(timezone.utc).isoformat() + 'Z' - # Write updated files + # Write updated files with comment preservation with open('.pre-commit-config.yaml', 'w') as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) + yaml.dump(config, f) with open('configs/precommit-update-tracking.json', 'w') as f: json.dump(tracking, f, indent=2) From 5b7ac71c43d7f884094cd5e601a98099d48ca1f8 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:12:02 +0100 Subject: [PATCH 19/25] fix: revert to pyyaml for dependency installation and YAML parsing --- .github/workflows/auto-update-precommit-hooks.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index ccffc0a..0935b78 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -89,7 +89,7 @@ jobs: - name: Install dependencies run: | - pip install ruamel.yaml + pip install pyyaml - name: Detect available updates id: detect @@ -97,7 +97,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | python3 << 'EOF' - from ruamel.yaml import YAML + import yaml import json import subprocess import os From 5d48b91ec8ac28b05873be567608b82b7b01ca52 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:14:44 +0100 Subject: [PATCH 20/25] fix: handle potential errors when adding comments to repo entries --- .github/workflows/auto-update-precommit-hooks.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 0935b78..f575795 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -575,8 +575,14 @@ jobs: repo_entry['rev'] = update['new_sha'] # Add version as inline comment (e.g., # frozen: v1.2.3) - if hasattr(repo_entry, 'ca'): - repo_entry.ca.comment[None] = [None, f' frozen: {update["new_version"]}'] + try: + if hasattr(repo_entry, 'ca') and repo_entry.ca is not None: + if repo_entry.ca.comment is None: + repo_entry.ca.comment = {} + repo_entry.ca.comment[None] = [None, f' frozen: {update["new_version"]}'] + except Exception as e: + # If comment fails, silently continue - comments are optional + pass # Update tracking if repo_url not in tracking['hooks']: From 74ef6e05966350ae068cf019e40c84fd414e33c8 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:17:13 +0100 Subject: [PATCH 21/25] refactor: remove inline comment addition for version tracking in repo entries --- .github/workflows/auto-update-precommit-hooks.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index f575795..e93725e 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -574,16 +574,6 @@ jobs: update = update_map[repo_url] repo_entry['rev'] = update['new_sha'] - # Add version as inline comment (e.g., # frozen: v1.2.3) - try: - if hasattr(repo_entry, 'ca') and repo_entry.ca is not None: - if repo_entry.ca.comment is None: - repo_entry.ca.comment = {} - repo_entry.ca.comment[None] = [None, f' frozen: {update["new_version"]}'] - except Exception as e: - # If comment fails, silently continue - comments are optional - pass - # Update tracking if repo_url not in tracking['hooks']: tracking['hooks'][repo_url] = { From 63290a6fe14828daa1c3e2700e94e84afbd5a8fe Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:21:30 +0100 Subject: [PATCH 22/25] fix: skip updates if version hasn't changed to prevent unnecessary processing --- .github/workflows/auto-update-precommit-hooks.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index e93725e..d456e63 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -235,6 +235,11 @@ jobs: 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, From 0c7c66cea0c6f010c235b0d330c8f351d30346c2 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:43:37 +0100 Subject: [PATCH 23/25] refactor: remove workflow_dispatch inputs for cooldown periods and skip hooks --- .../workflows/auto-update-precommit-hooks.yml | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index d456e63..5e7c1c2 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -28,33 +28,6 @@ on: 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" From 29f4dd36c02127f778239bdb190b51d0ec7715b1 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:51:04 +0100 Subject: [PATCH 24/25] fix: update inline comment for frozen version in repo entries and handle potential errors --- .../workflows/auto-update-precommit-hooks.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index 5e7c1c2..adc1c86 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -552,6 +552,22 @@ jobs: 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] = { From cad9b6dba91ef8d4f5b45abf7531524ca65f30c3 Mon Sep 17 00:00:00 2001 From: Colin Daglish Date: Thu, 10 Sep 2026 12:53:05 +0100 Subject: [PATCH 25/25] feat: add workflow_dispatch inputs for cooldown periods and hook skipping --- .../workflows/auto-update-precommit-hooks.yml | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/auto-update-precommit-hooks.yml b/.github/workflows/auto-update-precommit-hooks.yml index adc1c86..3730230 100644 --- a/.github/workflows/auto-update-precommit-hooks.yml +++ b/.github/workflows/auto-update-precommit-hooks.yml @@ -28,6 +28,33 @@ on: 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"