From 4bb9231e5b3c9bde6c6eb19bbf9a3daa2bafc677 Mon Sep 17 00:00:00 2001 From: codemastervy Date: Sun, 30 Aug 2026 19:56:13 +1200 Subject: [PATCH] Replace continuous Dependabot auto-merge with a bimonthly, approval-gated update Removes dependabot.yml and the auto-merge-on-open workflow entirely -- no more individual dependency PRs appearing and merging on their own. Adds one workflow that runs itself every 2 months (or on demand): it checks pip, npm and the Docker base images for anything newer, and if it finds something, PAUSES on a GitHub Environment ('dependency-approval') that requires manual review. That pause is what makes GitHub send an email with a link to an Approve/Reject button -- no third-party mail service, no credentials, just GitHub's own notification system. Only after approval does it apply the bumps, open one consolidated PR, and request auto-merge -- which still can't complete until backend-tests, frontend-build and docker-build all pass, same as any other PR, enforced by the branch protection already in place. A dependency check that finds nothing sends no email at all. --- .github/dependabot.yml | 31 ---- .../workflows/bimonthly-dependency-update.yml | 133 ++++++++++++++++++ .github/workflows/dependabot-auto-merge.yml | 22 --- scripts/apply_docker_update.py | 13 ++ scripts/apply_pip_updates.py | 23 +++ scripts/check_docker_base.py | 72 ++++++++++ scripts/summarize_updates.py | 49 +++++++ 7 files changed, 290 insertions(+), 53 deletions(-) delete mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/bimonthly-dependency-update.yml delete mode 100644 .github/workflows/dependabot-auto-merge.yml create mode 100644 scripts/apply_docker_update.py create mode 100644 scripts/apply_pip_updates.py create mode 100644 scripts/check_docker_base.py create mode 100644 scripts/summarize_updates.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index b5188ad..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "pip" - directory: "/backend" - schedule: - interval: "weekly" - open-pull-requests-limit: 10 - commit-message: - prefix: "deps(backend)" - - - package-ecosystem: "npm" - directory: "/frontend" - schedule: - interval: "weekly" - open-pull-requests-limit: 10 - commit-message: - prefix: "deps(frontend)" - - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "weekly" - commit-message: - prefix: "deps(docker)" - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - commit-message: - prefix: "deps(ci)" diff --git a/.github/workflows/bimonthly-dependency-update.yml b/.github/workflows/bimonthly-dependency-update.yml new file mode 100644 index 0000000..7fc74b1 --- /dev/null +++ b/.github/workflows/bimonthly-dependency-update.yml @@ -0,0 +1,133 @@ +name: Bimonthly dependency update + +# Runs itself every 2 months, checks every dependency for a newer version, +# and PAUSES for your approval before touching anything. Approving is a +# real GitHub Environment review -- GitHub emails you (via your own +# notification settings) with a link to an Approve/Reject button on +# github.com. Nothing is applied until you click it, and even after you +# approve, the update still has to pass the same CI checks (backend +# tests, frontend build, Docker build) as any other change before it can +# merge -- branch protection on main enforces that regardless. +on: + schedule: + # 09:00 UTC on the 1st of every odd month -- an approximation of "every + # 2 months" using plain cron, which has no native "every N months" unit. + - cron: "0 9 1 1,3,5,7,9,11 *" + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + outputs: + has_updates: ${{ steps.summarize.outputs.has_updates }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Check pip + run: | + pip install -r backend/requirements.txt >/dev/null + pip list --outdated --format=json > pip-outdated.json + + - name: Check npm + run: | + cd frontend + npm ci >/dev/null 2>&1 + npm outdated --json > ../npm-outdated.json || true + + - name: Check Docker base images + run: python3 scripts/check_docker_base.py > docker-outdated.json + + - name: Summarize + id: summarize + run: | + python3 scripts/summarize_updates.py \ + pip-outdated.json npm-outdated.json docker-outdated.json + if [ -s summary.md ]; then + echo "has_updates=true" >> "$GITHUB_OUTPUT" + else + echo "has_updates=false" >> "$GITHUB_OUTPUT" + fi + cat summary.md >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true + + - uses: actions/upload-artifact@v4 + with: + name: outdated-reports + path: | + pip-outdated.json + npm-outdated.json + docker-outdated.json + summary.md + + # This job's only purpose is the `environment:` line below. A job that + # targets an Environment with a required reviewer configured will not + # start until that reviewer approves it -- which is what generates the + # GitHub notification email with the Approve/Reject link. + await-approval: + needs: check + if: needs.check.outputs.has_updates == 'true' + runs-on: ubuntu-latest + environment: dependency-approval + steps: + - run: echo "Approved -- applying the updates found above." + + apply: + needs: [check, await-approval] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "20" + - uses: actions/download-artifact@v4 + with: + name: outdated-reports + + - name: Bump backend/requirements.txt + run: python3 scripts/apply_pip_updates.py + + - name: Bump frontend dependencies (npm-check-updates) + run: | + cd frontend + npx --yes npm-check-updates -u + npm install + + - name: Bump Dockerfile base images + run: python3 scripts/apply_docker_update.py + + - name: Commit, open a PR, and request auto-merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + BRANCH="deps/bimonthly-$(date +%Y-%m-%d)" + git checkout -b "$BRANCH" + git add -A + + if git diff --cached --quiet; then + echo "Nothing actually changed (versions may already be current)." + exit 0 + fi + + git commit -m "Bimonthly dependency update ($(date +%Y-%m-%d))" + git push -u origin "$BRANCH" + + BODY=$'Approved via the `dependency-approval` environment on '"$(date +%Y-%m-%d)"$'.\n\nThis still has to pass backend-tests, frontend-build and docker-build --\nrequired by branch protection on `main` -- before it can merge. If any of\nthose fail, this PR stays open, unmerged, for you to look at.' + PR_URL=$(gh pr create --title "Bimonthly dependency update ($(date +%Y-%m-%d))" --body "$BODY") + + # This does NOT merge immediately: it only takes effect once every + # required status check on this PR has passed. + gh pr merge --auto --squash "$PR_URL" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index 72d09d8..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Dependabot auto-merge - -on: pull_request - -permissions: - pull-requests: write - contents: write - -jobs: - auto-merge: - if: github.actor == 'dependabot[bot]' - runs-on: ubuntu-latest - steps: - # Enabling auto-merge does NOT merge immediately -- GitHub holds the - # merge until every required status check on this PR (the CI workflow - # above) reports success. If a dependency bump breaks the build or the - # test suite, this PR sits open for a human to look at; nothing lands - # on main unattended. - - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/apply_docker_update.py b/scripts/apply_docker_update.py new file mode 100644 index 0000000..7d461ce --- /dev/null +++ b/scripts/apply_docker_update.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Rewrite Dockerfile FROM lines using docker-outdated.json.""" +import json +import re + +with open("docker-outdated.json") as fh: + updates = {u["image"]: (u["current"], u["latest"]) for u in json.load(fh)} + +path = "Dockerfile" +text = open(path).read() +for image, (current, latest) in updates.items(): + text = text.replace(f"FROM {image}:{current}", f"FROM {image}:{latest}") +open(path, "w").write(text) diff --git a/scripts/apply_pip_updates.py b/scripts/apply_pip_updates.py new file mode 100644 index 0000000..d233eb2 --- /dev/null +++ b/scripts/apply_pip_updates.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Bump every package in backend/requirements.txt to the version +pip-outdated.json says is latest. Leaves anything not in that file alone.""" +import json +import re + +with open("pip-outdated.json") as fh: + outdated = {p["name"].lower(): p["latest_version"] for p in json.load(fh)} + +path = "backend/requirements.txt" +lines = open(path).read().splitlines() +out = [] +for line in lines: + m = re.match(r'^([A-Za-z0-9_.\-\[\]]+)==([\w.]+)$', line.strip()) + if not m: + out.append(line) + continue + name, current = m.groups() + bare_name = re.sub(r'\[.*\]$', '', name).lower() + latest = outdated.get(bare_name) + out.append(f"{name}=={latest}" if latest else line) + +open(path, "w").write("\n".join(out) + "\n") diff --git a/scripts/check_docker_base.py b/scripts/check_docker_base.py new file mode 100644 index 0000000..2a28f41 --- /dev/null +++ b/scripts/check_docker_base.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Report the newest Docker Hub tag on the same track as each FROM line. + +"Same track" means: matching suffix (e.g. "-slim-bookworm"), AND the same +number of dot-separated version components as what's currently pinned -- +so a Dockerfile pinned to the major-only "20-bookworm-slim" gets offered +"26-bookworm-slim", never a fully-specified "26.8.1-bookworm-slim" that +would silently change the pinning granularity as a side effect of a +version bump. +""" +import json +import re +import sys +import urllib.request + +FROM_RE = re.compile(r'^FROM\s+([\w.-]+):([\w.-]+)', re.MULTILINE) + + +def _version_key(v: str) -> tuple: + return tuple(int(p) for p in v.split(".")) + + +def newest_tag(image: str, current: str) -> str | None: + m = re.match(r'^([\d.]+)(-.*)?$', current) + if not m: + return None + current_version, suffix = m.group(1), (m.group(2) or "") + components = len(current_version.split(".")) + pattern = re.compile(r'^(\d+(?:\.\d+){%d})%s$' % (components - 1, re.escape(suffix))) + + best = None + url = f"https://hub.docker.com/v2/repositories/library/{image}/tags?page_size=100" + for _ in range(5): # a few pages is enough for an actively maintained image + if not url: + break + with urllib.request.urlopen(url, timeout=15) as resp: + data = json.load(resp) + for entry in data.get("results", []): + m2 = pattern.match(entry.get("name", "")) + if not m2: + continue + candidate = m2.group(1) + if best is None or _version_key(candidate) > _version_key(best): + best = candidate + url = data.get("next") + return f"{best}{suffix}" if best else None + + +def main() -> None: + dockerfile = open("Dockerfile").read() + updates = [] + for image, current in FROM_RE.findall(dockerfile): + if image not in ("python", "node"): + continue + try: + newest = newest_tag(image, current) + except Exception as exc: # noqa: BLE001 + # Visible in the CI log rather than silently reporting "nothing + # to update" when the real story is "the check itself failed". + print(f"WARNING: could not check {image}:{current}: {exc}", + file=sys.stderr) + continue + if newest and newest != current: + m_new = re.match(r'^([\d.]+)', newest) + m_cur = re.match(r'^([\d.]+)', current) + if _version_key(m_new.group(1)) > _version_key(m_cur.group(1)): + updates.append({"image": image, "current": current, "latest": newest}) + json.dump(updates, sys.stdout, indent=2) + + +if __name__ == "__main__": + main() diff --git a/scripts/summarize_updates.py b/scripts/summarize_updates.py new file mode 100644 index 0000000..e00fd5f --- /dev/null +++ b/scripts/summarize_updates.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Turn pip-outdated.json / npm-outdated.json / docker-outdated.json into a +short markdown summary, and report whether there is anything to do at all.""" +import json +import sys + + +def load(path): + try: + with open(path) as fh: + content = fh.read().strip() + return json.loads(content) if content else None + except (FileNotFoundError, ValueError): + return None + + +def main(): + pip_path, npm_path, docker_path = sys.argv[1:4] + lines = [] + + pip_data = load(pip_path) or [] + if pip_data: + lines.append("### Python (backend/requirements.txt)") + for pkg in pip_data: + lines.append(f"- `{pkg['name']}` {pkg['version']} -> {pkg['latest_version']}") + + npm_data = load(npm_path) or {} + if npm_data: + lines.append("### npm (frontend)") + for name, info in npm_data.items(): + current = info.get("current", "?") + latest = info.get("latest", "?") + lines.append(f"- `{name}` {current} -> {latest}") + + docker_data = load(docker_path) or [] + if docker_data: + lines.append("### Docker base images") + for entry in docker_data: + lines.append(f"- `{entry['image']}` {entry['current']} -> {entry['latest']}") + + summary = "\n".join(lines) + with open("summary.md", "w") as fh: + fh.write(summary) + # Non-empty summary is the only signal the workflow needs. + print("has updates" if summary.strip() else "no updates", file=sys.stderr) + + +if __name__ == "__main__": + main()