This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch replace-dependabot-with-update-workflow in repository https://gitbox.apache.org/repos/asf/airflow-site-archive.git
commit bfc203e397ae1a3763b6bcefe4bb706bb178efdf Author: Jarek Potiuk <[email protected]> AuthorDate: Sun Aug 9 16:13:26 2026 +0800 Replace Dependabot with a sparse-checkout update workflow Dependabot clones the whole repository before its file fetcher runs. At 826k files and 47 GB that never finishes inside a Dependabot job, so the runs time out and the pinned actions and pre-commit hooks fall behind - only one Dependabot PR has landed here since April. There is no knob in dependabot.yml that avoids the clone, so this drops the config and does the same job from a workflow that only ever needs the configuration files: * .github/workflows/update-pinned-deps.yml runs weekly (and on demand), once per target branch, on a sparse blobless checkout of .github. * scripts/update_pinned_deps.py resolves each SHA-pinned action and pre-commit hook to the newest release outside a 7-day cooldown window - the same cooldown the Dependabot config used - and rewrites the pins in place, preserving the `# vX.Y.Z` / `# frozen: vX.Y.Z` style. Branch pins such as `# main` move along their branch instead. Pull requests opened with GITHUB_TOKEN do not trigger other workflows, so the workflow runs pre-commit on the changed files itself before opening the PR. Dependabot security alerts are unaffected - they do not come from dependabot.yml. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- .github/dependabot.yml | 63 ------- .github/workflows/update-pinned-deps.yml | 131 ++++++++++++++ scripts/update_pinned_deps.py | 300 +++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 63 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index e2da1f1f0fc..00000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,63 +0,0 @@ - -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - ---- -version: 2 -updates: - - package-ecosystem: github-actions - cooldown: - default-days: 7 - directory: / - schedule: - interval: daily - groups: - github-actions: - patterns: - - ".github/*" - - package-ecosystem: github-actions - cooldown: - default-days: 7 - directory: / - target-branch: staging - schedule: - interval: daily - groups: - github-actions: - patterns: - - ".github/*" - - package-ecosystem: pre-commit - cooldown: - default-days: 7 - directory: / - schedule: - interval: weekly - groups: - pre-commit-hooks: - patterns: - - ".pre-commit-config.yaml" - - package-ecosystem: pre-commit - cooldown: - default-days: 7 - directory: / - target-branch: staging - schedule: - interval: weekly - groups: - pre-commit-hooks: - patterns: - - ".pre-commit-config.yaml" diff --git a/.github/workflows/update-pinned-deps.yml b/.github/workflows/update-pinned-deps.yml new file mode 100644 index 00000000000..4351eb2a7ec --- /dev/null +++ b/.github/workflows/update-pinned-deps.yml @@ -0,0 +1,131 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +--- +name: Update pinned dependencies +# Replaces Dependabot for GitHub Actions and pre-commit hooks. Dependabot clones +# the whole repository before its file fetcher runs, and at 800k+ files / 47+ GB +# that never finishes inside a Dependabot job - so updates simply stopped +# landing. This workflow only needs the configuration files, which a sparse +# blobless checkout gets in seconds. +on: # yamllint disable-line rule:truthy + schedule: + - cron: '0 6 * * 1' # Mondays, 06:00 UTC + workflow_dispatch: + inputs: + cooldown-days: + description: "Ignore releases published more recently than this many days" + default: "7" + type: string +permissions: + contents: read +concurrency: + group: update-pinned-deps + cancel-in-progress: false +jobs: + update: + name: "Update pins on ${{ matrix.branch }}" + runs-on: ubuntu-latest + if: github.repository == 'apache/airflow-site-archive' + permissions: + contents: write # push the update branch + pull-requests: write # open the update pull request + strategy: + fail-fast: false + matrix: + branch: [main, staging] + env: + TARGET_BRANCH: ${{ matrix.branch }} + UPDATE_BRANCH: update-pinned-deps-${{ matrix.branch }} + COOLDOWN_DAYS: ${{ inputs.cooldown-days || '7' }} + steps: + - name: "Checkout ${{ matrix.branch }} configuration" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Only the configuration is needed - never check out docs-archive here. + with: + ref: ${{ matrix.branch }} + fetch-depth: 1 + filter: blob:none + sparse-checkout: | + .github + persist-credentials: false + + - name: "Checkout ${{ github.sha }} for scripts" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The updater always comes from the ref this workflow runs from, so it + # does not have to exist on the branch being updated yet. + with: + ref: ${{ github.sha }} + path: ./tooling + fetch-depth: 1 + filter: blob:none + sparse-checkout: | + scripts + persist-credentials: false + + - name: "Install uv" + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: "Resolve the latest versions outside the cooldown window" + id: update + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run --no-project ./tooling/scripts/update_pinned_deps.py \ + --repo-root . \ + --cooldown-days "${COOLDOWN_DAYS}" \ + --summary-file "${RUNNER_TEMP}/summary.md" + + - name: "Lint the updated configuration" + if: steps.update.outputs.updated == 'true' + run: | + uv tool install pre-commit --with pre-commit-uv + # Pull requests opened with GITHUB_TOKEN do not trigger other + # workflows, so the checks that would run on the pull request are run + # here instead - on the changed files only, because `--all-files` + # would walk all 800k+ files in the index. + git diff --name-only -z | xargs -0 pre-commit run --color always --files + + - name: "Open the update pull request" + if: steps.update.outputs.updated == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "${UPDATE_BRANCH}" + git commit -a -m "Update pinned GitHub Actions and pre-commit hooks" + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/${UPDATE_BRANCH}" + if [[ -n "$(gh pr list --head "${UPDATE_BRANCH}" --base "${TARGET_BRANCH}" \ + --state open --json number --jq '.[].number')" ]]; then + echo "Pull request already open - the force push updated it" + exit 0 + fi + { + echo "Automated update of the SHA-pinned GitHub Actions and pre-commit hooks," + echo "honouring a ${COOLDOWN_DAYS}-day cooldown on new releases." + echo + cat "${RUNNER_TEMP}/summary.md" + echo + echo "Opened by [\`update-pinned-deps.yml\`](.github/workflows/update-pinned-deps.yml)." + echo "Pre-commit ran on the changed files in that workflow run, because pull" + echo "requests opened with \`GITHUB_TOKEN\` do not trigger \`Basic checks\`." + } > "${RUNNER_TEMP}/pr-body.md" + gh pr create --base "${TARGET_BRANCH}" --head "${UPDATE_BRANCH}" \ + --title "Update pinned GitHub Actions and pre-commit hooks (${TARGET_BRANCH})" \ + --body-file "${RUNNER_TEMP}/pr-body.md" diff --git a/scripts/update_pinned_deps.py b/scripts/update_pinned_deps.py new file mode 100755 index 00000000000..7fd47a0819b --- /dev/null +++ b/scripts/update_pinned_deps.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// +"""Update SHA-pinned GitHub Actions and pre-commit hooks to their latest versions. + +This replaces Dependabot for the `github-actions` and `pre-commit` ecosystems. +Dependabot clones the whole repository before its file fetcher runs, and this +repository is far too big for that (800k+ files, 47+ GB), so every Dependabot +job times out and updates never land. This script only ever reads +`.github/workflows`, `.github/actions` and `.pre-commit-config.yaml`, so it runs +from a sparse checkout in seconds. + +Only pinned references are touched, and the pinning style is preserved: + + uses: actions/checkout@<sha> # v7.0.1 -> newest release tag + uses: apache/some-action@<sha> # main -> newest commit on that branch + rev: <sha> # frozen: v1.29.0 -> newest release tag + +A cooldown window (7 days by default, matching the Dependabot config this +replaces) keeps us off releases that are still hot. + +Set GITHUB_TOKEN (or GH_TOKEN) to avoid the unauthenticated API rate limit. +""" + +from __future__ import annotations + +import argparse +import http.client +import json +import os +import re +import sys +import urllib.error +import urllib.request +from datetime import UTC, datetime, timedelta +from pathlib import Path +from time import sleep + +API_ROOT = "https://api.github.com" +API_ATTEMPTS = 3 +# Enough history to find the newest release outside the cooldown window without +# pulling megabytes of release notes for repositories that publish a lot. +API_PAGE_SIZE = 30 + +# `uses: owner/repo[/sub/path]@<40-hex sha> # <tag-or-branch>` +USES_RE = re.compile( + r"(?P<prefix>uses:\s+)" + r"(?P<repo>[A-Za-z0-9._-]+/[A-Za-z0-9._-]+)" + r"(?P<subpath>(?:/[^@\s]+)?)" + r"@(?P<sha>[0-9a-f]{40})" + r"(?P<gap>\s+#\s+)" + r"(?P<comment>\S+)" +) +# `- repo: https://github.com/owner/repo` +PRE_COMMIT_REPO_RE = re.compile(r"^\s*-\s+repo:\s+https://github\.com/(?P<repo>[^\s/]+/[^\s/]+?)(?:\.git)?\s*$") +# `rev: <sha> # frozen: v1.29.0` +PRE_COMMIT_REV_RE = re.compile(r"^(?P<prefix>\s*rev:\s+)(?P<rev>\S+)(?P<gap>\s+#\s+frozen:\s+)(?P<tag>\S+)\s*$") +# Strict release versions only - no prereleases, no moving major/minor tags. +VERSION_RE = re.compile(r"^v?(?P<version>\d+(?:\.\d+)*)$") + + +class GitHubError(RuntimeError): + """Raised when the GitHub API cannot answer a question we need answered.""" + + +def api(path: str, **params: str | int) -> list | dict | None: + """Call the GitHub REST API, returning None for a 404. + + Transient failures (dropped connections, truncated responses, 5xx) are + retried - a single flaky read should not fail the whole update run. + """ + url = f"{API_ROOT}/{path}" + if params: + url += "?" + "&".join(f"{key}={value}" for key, value in params.items()) + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + request.add_header("Authorization", f"Bearer {token}") + for attempt in range(1, API_ATTEMPTS + 1): + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as error: + if error.code == 404: + return None + if error.code < 500 or attempt == API_ATTEMPTS: + raise GitHubError(f"GET {url} failed: {error.code} {error.reason}") from error + except (urllib.error.URLError, http.client.HTTPException, TimeoutError) as error: + if attempt == API_ATTEMPTS: + raise GitHubError(f"GET {url} failed: {error}") from error + sleep(attempt) + return None + + +def parse_version(tag: str) -> tuple[int, ...] | None: + """Turn `v1.29.0` into `(1, 29, 0)`, or return None if it is not a release version.""" + match = VERSION_RE.match(tag) + if not match: + return None + return tuple(int(part) for part in match.group("version").split(".")) + + +def released_at(entry: dict) -> datetime: + return datetime.fromisoformat(entry["published_at"]) + + +def latest_release_tag(repo: str, cutoff: datetime) -> str | None: + """Newest release tag of `repo` that was published before `cutoff`. + + Releases are preferred over tags because they carry a publication date and + exclude drafts, prereleases and moving major tags such as `v7`. Repositories + that do not publish releases fall back to tags, dated by their commit. + """ + releases = api(f"repos/{repo}/releases", per_page=API_PAGE_SIZE) or [] + candidates = [ + (version, release["tag_name"]) + for release in releases + if not release["draft"] and not release["prerelease"] and released_at(release) <= cutoff + for version in [parse_version(release["tag_name"])] + if version + ] + if candidates: + return max(candidates)[1] + + tags = api(f"repos/{repo}/tags", per_page=API_PAGE_SIZE) or [] + dated = sorted( + ((version, tag["name"], tag["commit"]["sha"]) for tag in tags for version in [parse_version(tag["name"])] if version), + reverse=True, + ) + for _, name, sha in dated: + commit = api(f"repos/{repo}/commits/{sha}") + if commit and datetime.fromisoformat(commit["commit"]["committer"]["date"]) <= cutoff: + return name + return None + + +def tag_sha(repo: str, tag: str) -> str: + """Commit SHA a tag points at, dereferencing annotated tags.""" + reference = api(f"repos/{repo}/git/ref/tags/{tag}") + if not reference: + raise GitHubError(f"tag {tag} not found in {repo}") + target = reference["object"] + if target["type"] == "tag": + target = api(f"repos/{repo}/git/tags/{target['sha']}")["object"] + return target["sha"] + + +def latest_branch_sha(repo: str, branch: str, cutoff: datetime) -> str | None: + """Newest commit on `branch` that is older than `cutoff`.""" + commits = api(f"repos/{repo}/commits", sha=branch, until=cutoff.strftime("%Y-%m-%dT%H:%M:%SZ"), per_page=1) + return commits[0]["sha"] if commits else None + + +class Updater: + """Resolves and applies pin updates, collecting a report as it goes.""" + + def __init__(self, cutoff: datetime, dry_run: bool = False) -> None: + self.cutoff = cutoff + self.dry_run = dry_run + self.updates: list[tuple[str, str, str]] = [] + self.resolved: dict[tuple[str, str], tuple[str, str] | None] = {} + + def write(self, path: Path, content: str) -> None: + if not self.dry_run: + path.write_text(content) + + def resolve(self, repo: str, current_tag: str, current_sha: str) -> tuple[str, str] | None: + """Return the (tag, sha) this pin should move to, or None to leave it alone.""" + key = (repo, current_tag) + if key not in self.resolved: + self.resolved[key] = self._resolve(repo, current_tag, current_sha) + target = self.resolved[key] + return target if target and target[1] != current_sha else None + + def _resolve(self, repo: str, current_tag: str, current_sha: str) -> tuple[str, str] | None: + current_version = parse_version(current_tag) + if current_version is None: + # A branch pin such as `# main` - move it along the branch instead. + sha = latest_branch_sha(repo, current_tag, self.cutoff) + return (current_tag, sha) if sha else None + tag = latest_release_tag(repo, self.cutoff) + if tag is None: + print(f" ! {repo}: no release older than the cooldown window, left at {current_tag}") + return None + if parse_version(tag) < current_version: + print(f" ! {repo}: newest eligible release {tag} is older than the pinned {current_tag}, left alone") + return None + return tag, tag_sha(repo, tag) + + def record(self, name: str, before_tag: str, before_sha: str, after_tag: str, after_sha: str) -> None: + if before_tag == after_tag: + # A branch pin, or a tag that was moved to a different commit - the + # tag alone would read as a no-op, so show what actually changed. + before, after = f"{before_tag} ({before_sha[:7]})", f"{after_tag} ({after_sha[:7]})" + else: + before, after = before_tag, after_tag + # The same action is typically pinned in several places - report it once. + if (name, before, after) in self.updates: + return + self.updates.append((name, before, after)) + print(f" * {name}: {before} -> {after}") + + def update_workflows(self, paths: list[Path]) -> None: + for path in sorted(paths): + content = original = path.read_text() + for match in list(USES_RE.finditer(original)): + repo, current_tag, current_sha = match["repo"], match["comment"], match["sha"] + target = self.resolve(repo, current_tag, current_sha) + if not target: + continue + tag, sha = target + replacement = ( + f"{match['prefix']}{repo}{match['subpath']}@{sha}{match['gap']}{tag}" + ) + content = content.replace(match.group(0), replacement) + self.record(f"{repo}{match['subpath']}", current_tag, current_sha, tag, sha) + if content != original: + self.write(path, content) + + def update_pre_commit(self, path: Path) -> None: + if not path.exists(): + return + lines = path.read_text().splitlines(keepends=True) + repo: str | None = None + for index, line in enumerate(lines): + repo_match = PRE_COMMIT_REPO_RE.match(line) + if repo_match: + repo = repo_match["repo"] + continue + rev_match = PRE_COMMIT_REV_RE.match(line) + if not (repo and rev_match): + continue + target = self.resolve(repo, rev_match["tag"], rev_match["rev"]) + if not target: + continue + tag, sha = target + lines[index] = f"{rev_match['prefix']}{sha}{rev_match['gap']}{tag}\n" + self.record(repo, rev_match["tag"], rev_match["rev"], tag, sha) + self.write(path, "".join(lines)) + + def report(self) -> str: + if not self.updates: + return "No updates available outside the cooldown window.\n" + widths = [max(len(row[column]) for row in self.updates) for column in range(3)] + header = f"| {'Dependency':<{widths[0]}} | {'From':<{widths[1]}} | {'To':<{widths[2]}} |\n" + divider = f"|{'-' * (widths[0] + 2)}|{'-' * (widths[1] + 2)}|{'-' * (widths[2] + 2)}|\n" + rows = "".join( + f"| {name:<{widths[0]}} | {before:<{widths[1]}} | {after:<{widths[2]}} |\n" + for name, before, after in self.updates + ) + return header + divider + rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--cooldown-days", type=int, default=7, help="ignore releases newer than this many days") + parser.add_argument("--repo-root", type=Path, default=Path(__file__).parent.parent, help="repository root") + parser.add_argument("--summary-file", type=Path, help="write the markdown summary table here") + parser.add_argument("--dry-run", action="store_true", help="report what would change without writing files") + args = parser.parse_args() + + now = datetime.now(UTC) + cutoff = now - timedelta(days=args.cooldown_days) + print(f"Updating pins to the newest versions released before {cutoff:%Y-%m-%d %H:%M} UTC " + f"({args.cooldown_days}-day cooldown)") + + workflows = sorted( + { + *(args.repo_root / ".github" / "workflows").glob("*.y*ml"), + *(args.repo_root / ".github" / "actions").rglob("*.y*ml"), + } + ) + pre_commit_config = args.repo_root / ".pre-commit-config.yaml" + + updater = Updater(cutoff, dry_run=args.dry_run) + try: + print("GitHub Actions:") + updater.update_workflows(workflows) + print("Pre-commit hooks:") + updater.update_pre_commit(pre_commit_config) + except GitHubError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + summary = updater.report() + print(summary, end="") + if args.summary_file: + args.summary_file.write_text(summary) + if step_summary := os.environ.get("GITHUB_STEP_SUMMARY"): + Path(step_summary).write_text(f"### Pinned dependency updates\n\n{summary}") + if github_output := os.environ.get("GITHUB_OUTPUT"): + with Path(github_output).open("a") as output: + output.write(f"updated={'true' if updater.updates else 'false'}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())
