Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 0 additions & 31 deletions .github/dependabot.yml

This file was deleted.

133 changes: 133 additions & 0 deletions .github/workflows/bimonthly-dependency-update.yml
Original file line number Diff line number Diff line change
@@ -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"
22 changes: 0 additions & 22 deletions .github/workflows/dependabot-auto-merge.yml

This file was deleted.

13 changes: 13 additions & 0 deletions scripts/apply_docker_update.py
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 23 additions & 0 deletions scripts/apply_pip_updates.py
Original file line number Diff line number Diff line change
@@ -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")
72 changes: 72 additions & 0 deletions scripts/check_docker_base.py
Original file line number Diff line number Diff line change
@@ -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()
49 changes: 49 additions & 0 deletions scripts/summarize_updates.py
Original file line number Diff line number Diff line change
@@ -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()
Loading