neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Security update pull requests from CI (B)
9c7f1f9 · Gabriel Morell · 2026-10-22 21:17
Message
{commit_body(@commit)}
Files changed
modified
.forgejo/workflows/audit.yml
+23
−0
@@ -6,12 +6,17 @@ name: audit
| 6 | 6 | # `gg ci list`. Nightly, since advisories land without anyone pushing, |
| 7 | 7 | # and by hand from the Actions page. |
| 8 | 8 | # |
| 9 | +# On a pull request it scans only the lockfiles the pull request | |
| 10 | +# changes, so an update PR (dependency-updates.yml) goes green once what | |
| 11 | +# it touches is clean, whatever is still open elsewhere. | |
| 12 | +# | |
| 9 | 13 | # The Android app isn't covered: osv-scanner reads Gradle lockfiles, |
| 10 | 14 | # and the app has a version catalog (gradle/libs.versions.toml) only. |
| 11 | 15 | on: |
| 12 | 16 | schedule: |
| 13 | 17 | - cron: "17 6 * * *" |
| 14 | 18 | workflow_dispatch: |
| 19 | + pull_request: | |
| 15 | 20 | |
| 16 | 21 | permissions: |
| 17 | 22 | contents: read |
@@ -21,6 +26,8 @@ jobs:
| 21 | 26 | runs-on: self-hosted |
| 22 | 27 | steps: |
| 23 | 28 | - uses: actions/checkout@v4 |
| 29 | + with: | |
| 30 | + fetch-depth: 0 | |
| 24 | 31 | |
| 25 | 32 | # Pinned, and checked against the release's published checksum |
| 26 | 33 | # before it runs: a scanner is a strange thing to take on trust. |
@@ -39,4 +46,20 @@ jobs:
| 39 | 46 | # Exit 1 when anything is vulnerable, so the run fails; the table |
| 40 | 47 | # names each package, its version, and the version that fixes it. |
| 41 | 48 | - name: Scan the lockfiles |
| 49 | + if: github.event_name != 'pull_request' | |
| 42 | 50 | run: /tmp/osv-scanner scan source --recursive . |
| 51 | + | |
| 52 | + - name: Scan the lockfiles this pull request changes | |
| 53 | + if: github.event_name == 'pull_request' | |
| 54 | + env: | |
| 55 | + BASE: ${{ github.base_ref }} | |
| 56 | + run: | | |
| 57 | + git fetch --quiet origin "$BASE" | |
| 58 | + changed=$(git diff --name-only "origin/$BASE"...HEAD | grep -E '(^|/)(mix|Cargo)\.lock$' || true) | |
| 59 | + if [ -z "$changed" ]; then | |
| 60 | + echo "No lockfile changed." | |
| 61 | + exit 0 | |
| 62 | + fi | |
| 63 | + echo "$changed" | |
| 64 | + set -- $(printf -- '--lockfile %s ' $changed) | |
| 65 | + /tmp/osv-scanner scan source "$@" |
added
.forgejo/workflows/dependency-updates.yml
+65
−0
@@ -0,0 +1,65 @@
| 1 | +name: dependency-updates | |
| 2 | +# Security updates as pull requests: scripts/dependency-update finds the | |
| 3 | +# dependencies with a known vulnerability (osv-scanner, against OSV) that | |
| 4 | +# a newer version fixes, bumps them, and keeps one pull request per | |
| 5 | +# ecosystem — deps/security-hex, deps/security-cargo — refreshed in | |
| 6 | +# place, closing it once there's nothing left to fix. The pull requests | |
| 7 | +# are opened by the forge's CI account (gg-security) with this job's own | |
| 8 | +# token; their CI (audit.yml among it) is what shows the fixes landed. | |
| 9 | +# | |
| 10 | +# Weekly, and by hand from the Actions page (`gg ci dispatch | |
| 11 | +# dependency-updates`). | |
| 12 | +on: | |
| 13 | + schedule: | |
| 14 | + - cron: "23 5 * * 1" | |
| 15 | + workflow_dispatch: | |
| 16 | + | |
| 17 | +permissions: | |
| 18 | + contents: write # push the deps/security-* branches | |
| 19 | + pull-requests: write # open, refresh and close their pull requests | |
| 20 | + | |
| 21 | +jobs: | |
| 22 | + update: | |
| 23 | + runs-on: self-hosted | |
| 24 | + steps: | |
| 25 | + - uses: actions/checkout@v4 | |
| 26 | + with: | |
| 27 | + # The deps/security-* branches too, to tell whether a refresh | |
| 28 | + # changed anything before pushing it. | |
| 29 | + fetch-depth: 0 | |
| 30 | + | |
| 31 | + # The same toolchains the Dockerfile builds with: a lockfile is only | |
| 32 | + # as good as the resolver that wrote it. | |
| 33 | + - name: Say which Ubuntu this is, for setup-beam | |
| 34 | + run: echo "ImageOS=ubuntu$(. /etc/os-release && echo "${VERSION_ID%%.*}")" >> "$GITHUB_ENV" | |
| 35 | + | |
| 36 | + - uses: erlef/setup-beam@v1 | |
| 37 | + with: | |
| 38 | + otp-version: "28.5.0.2" | |
| 39 | + elixir-version: "1.19.5" | |
| 40 | + version-type: strict | |
| 41 | + | |
| 42 | + - name: Hex | |
| 43 | + run: mix local.hex --force | |
| 44 | + | |
| 45 | + - name: Rust | |
| 46 | + run: | | |
| 47 | + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal | |
| 48 | + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | |
| 49 | + | |
| 50 | + # Pinned and checked, as in audit.yml; bump both together. | |
| 51 | + - name: Fetch osv-scanner | |
| 52 | + env: | |
| 53 | + OSV_VERSION: v2.6.0 | |
| 54 | + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108 | |
| 55 | + run: | | |
| 56 | + curl -sSfL -o /tmp/osv-scanner \ | |
| 57 | + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64" | |
| 58 | + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c - | |
| 59 | + chmod +x /tmp/osv-scanner | |
| 60 | + | |
| 61 | + - name: Update | |
| 62 | + env: | |
| 63 | + OSV_SCANNER: /tmp/osv-scanner | |
| 64 | + GITHUB_TOKEN: ${{ github.token }} | |
| 65 | + run: scripts/dependency-update --base "${{ github.ref_name }}" |
modified
docs/plans/dependency-security.md
+12
−1
@@ -1,6 +1,6 @@
| 1 | 1 | # Plan: dependency security for repositories on the forge |
| 2 | 2 | |
| 3 | −Status: **in progress**. Step 0, A1 and A2 are built (2026-10-22). | |
| 3 | +Status: **in progress**. Step 0, A1, A2 and B are built (2026-10-22). | |
| 4 | 4 | Decisions are under "Decisions"; open questions 3 and 8 still have only |
| 5 | 5 | recommended defaults. |
| 6 | 6 |
@@ -360,6 +360,17 @@ then `./gradlew dependencies --write-locks`) commits a
| 360 | 360 | update PR wouldn't re-run its CI. |
| 361 | 361 | 3. **B** — `scripts/dependency-update` and the workflow, on this |
| 362 | 362 | repository; then documented as a starter on `/run-your-own`. |
| 363 | + **Built** (`scripts/dependency-update`, Python 3, standard library; | |
| 364 | + `.forgejo/workflows/dependency-updates.yml`, weekly and by hand). | |
| 365 | + Hex and crates.io; Cargo goes to the lowest fixed version | |
| 366 | + (`--precise`), Hex as far as `mix.exs` allows past it (mix has no | |
| 367 | + `--precise`), and the pull request lists what came along with it. | |
| 368 | + `audit.yml` runs on pull requests too, scanning only the lockfiles | |
| 369 | + they change. A job refreshing its own update PR's branch now starts | |
| 370 | + that PR's `synchronize` runs (still one hop). Not yet: a starter on | |
| 371 | + `/run-your-own`, npm and Go, and a test run in the PR's CI — this | |
| 372 | + repository's pull requests run `audit.yml` only, so whether a bump | |
| 373 | + still builds is the reviewer's to check. | |
| 363 | 374 | 4. **C1** — osv-scanner in the image, scan on push, `security_alerts`, |
| 364 | 375 | the repository's Security tab. |
| 365 | 376 | 5. **C2** — the OSV mirror, nightly rescans, notifications and digest. |
modified
lib/git_gud/pull_requests.ex
+7
−1
@@ -613,7 +613,13 @@ defmodule GitGud.PullRequests do
| 613 | 613 | |
| 614 | 614 | # `pull_request` workflows' `synchronize`; for a fork PR, runs |
| 615 | 615 | # that wait for approval unless the pusher may write to the target. |
| 616 | − if Keyword.get(opts, :dispatch, true), | |
| 616 | + # A job-token push dispatches nothing, except for a pull request a | |
| 617 | + # job opened (`dispatch_job_prs:`, see `CommitBackfill`). | |
| 618 | + dispatch? = | |
| 619 | + Keyword.get(opts, :dispatch, true) or | |
| 620 | + (Keyword.get(opts, :dispatch_job_prs, false) and not is_nil(pr.workflow_run_id)) | |
| 621 | + | |
| 622 | + if dispatch?, | |
| 617 | 623 | do: :ok = PullRequestDispatch.enqueue(updated, "synchronize", pusher_id) |
| 618 | 624 | end |
| 619 | 625 | end |
modified
lib/git_gud/repositories/workers/commit_backfill.ex
+8
−1
@@ -10,6 +10,12 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
| 10 | 10 | `synchronize` — as on GitHub, where pushes made with `GITHUB_TOKEN` |
| 11 | 11 | don't, so a workflow that pushes can't trigger itself forever. Everything else (caches, |
| 12 | 12 | webhooks, pull request heads) happens as for any push. |
| 13 | + | |
| 14 | + One exception: a job refreshing the branch of a pull request a job | |
| 15 | + opened (an update PR, force-pushed with new bumps) does start that | |
| 16 | + pull request's `synchronize` runs — its CI is the point. Those runs | |
| 17 | + can't open issues or pull requests themselves | |
| 18 | + (`Workflows.Permissions.one_hop/2`), so this is one hop, not a loop. | |
| 13 | 19 | """ |
| 14 | 20 | |
| 15 | 21 | use Oban.Worker, queue: :git_cache, max_attempts: 5 |
@@ -93,7 +99,8 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
| 93 | 99 | |
| 94 | 100 | if branch do |
| 95 | 101 | GitGud.PullRequests.refresh_cross_repo_heads(repo, branch, new_sha, pusher_id, |
| 96 | − dispatch: dispatch? | |
| 102 | + dispatch: dispatch?, | |
| 103 | + dispatch_job_prs: true | |
| 97 | 104 | ) |
| 98 | 105 | end |
| 99 | 106 | end |
added
scripts/dependency-update
+374
−0
@@ -0,0 +1,374 @@
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Security updates for a repository's dependencies, as pull requests. | |
| 3 | + | |
| 4 | +Run by .forgejo/workflows/dependency-updates.yml. For each lockfile | |
| 5 | +osv-scanner reads, finds the packages with a known vulnerability that a | |
| 6 | +newer version fixes, bumps them to the lowest version that fixes every | |
| 7 | +advisory against them, and keeps one pull request per ecosystem up to | |
| 8 | +date with the result: | |
| 9 | + | |
| 10 | + deps/security-hex mix.lock files (mix deps.update) | |
| 11 | + deps/security-cargo Cargo.lock files (cargo update --precise) | |
| 12 | + | |
| 13 | +The branch is force-pushed and its pull request refreshed in place, so | |
| 14 | +there is never a pile of them; once an ecosystem has nothing left to | |
| 15 | +fix, its pull request is closed. Packages it can't fix — no fixed | |
| 16 | +version yet, or one the manifest's constraint won't reach — are listed | |
| 17 | +in the pull request, not touched. | |
| 18 | + | |
| 19 | +Needs: osv-scanner (OSV_SCANNER, default `osv-scanner`), mix and/or | |
| 20 | +cargo for the ecosystems present, git with push access, and in CI the | |
| 21 | +job's token (GITHUB_TOKEN) with `contents: write` and `pull-requests: | |
| 22 | +write`. Everything else comes from the job's environment | |
| 23 | +(GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_NUMBER). | |
| 24 | + | |
| 25 | + scripts/dependency-update do it | |
| 26 | + scripts/dependency-update --dry-run show what it would bump, change nothing | |
| 27 | + | |
| 28 | +Standard library only, so it runs wherever python3 does. | |
| 29 | +""" | |
| 30 | + | |
| 31 | +import argparse | |
| 32 | +import json | |
| 33 | +import os | |
| 34 | +import re | |
| 35 | +import subprocess | |
| 36 | +import sys | |
| 37 | +import urllib.error | |
| 38 | +import urllib.request | |
| 39 | + | |
| 40 | +ECOSYSTEMS = { | |
| 41 | + # OSV ecosystem → (branch suffix, lockfile name, human name) | |
| 42 | + "Hex": ("hex", "mix.lock", "Hex (mix)"), | |
| 43 | + "crates.io": ("cargo", "Cargo.lock", "crates.io (cargo)"), | |
| 44 | +} | |
| 45 | + | |
| 46 | +BOT_NAME = os.environ.get("GG_BOT_NAME", "gg-security") | |
| 47 | +BOT_EMAIL = os.environ.get("GG_BOT_EMAIL", "gg-security@bots.invalid") | |
| 48 | + | |
| 49 | + | |
| 50 | +# ── versions ──────────────────────────────────────────────────────────── | |
| 51 | + | |
| 52 | + | |
| 53 | +def vkey(version): | |
| 54 | + """Sort key for a semver-ish version: 1.2.3 < 1.2.10, 1.0.0-rc.1 < 1.0.0.""" | |
| 55 | + main, _, pre = version.partition("-") | |
| 56 | + nums = [] | |
| 57 | + for part in main.split("."): | |
| 58 | + m = re.match(r"\d+", part) | |
| 59 | + nums.append(int(m.group()) if m else 0) | |
| 60 | + nums += [0] * (3 - len(nums)) | |
| 61 | + if not pre: | |
| 62 | + return (nums, 1, ()) | |
| 63 | + pre_parts = tuple((0, int(p), "") if p.isdigit() else (1, 0, p) for p in pre.split(".")) | |
| 64 | + return (nums, 0, pre_parts) | |
| 65 | + | |
| 66 | + | |
| 67 | +def fixed_for(vuln, name, current): | |
| 68 | + """The version that fixes `vuln` for `name` at `current`, or None.""" | |
| 69 | + for affected in vuln.get("affected", []): | |
| 70 | + if affected.get("package", {}).get("name") != name: | |
| 71 | + continue | |
| 72 | + for rng in affected.get("ranges", []): | |
| 73 | + if rng.get("type") not in ("SEMVER", "ECOSYSTEM"): | |
| 74 | + continue | |
| 75 | + introduced = None | |
| 76 | + for event in rng.get("events", []): | |
| 77 | + if "introduced" in event: | |
| 78 | + introduced = event["introduced"] | |
| 79 | + elif "fixed" in event and introduced is not None: | |
| 80 | + low = "0" if introduced == "0" else introduced | |
| 81 | + if (low == "0" or vkey(low) <= vkey(current)) and vkey(current) < vkey(event["fixed"]): | |
| 82 | + return event["fixed"] | |
| 83 | + introduced = None | |
| 84 | + return None | |
| 85 | + | |
| 86 | + | |
| 87 | +# ── scanning ──────────────────────────────────────────────────────────── | |
| 88 | + | |
| 89 | + | |
| 90 | +def tracked_lockfiles(): | |
| 91 | + """The lockfiles git tracks, of the kinds handled here: not vendored | |
| 92 | + copies, scratch checkouts or anything else lying in the tree.""" | |
| 93 | + names = {lockname for _, lockname, _ in ECOSYSTEMS.values()} | |
| 94 | + listed = run(["git", "ls-files", "-z"]).stdout.split("\0") | |
| 95 | + return sorted(p for p in listed if os.path.basename(p) in names) | |
| 96 | + | |
| 97 | + | |
| 98 | +def scan(lockfiles): | |
| 99 | + """osv-scanner's JSON for `lockfiles`.""" | |
| 100 | + if not lockfiles: | |
| 101 | + return {} | |
| 102 | + scanner = os.environ.get("OSV_SCANNER", "osv-scanner") | |
| 103 | + args = [scanner, "scan", "source", "--format", "json"] | |
| 104 | + for path in lockfiles: | |
| 105 | + args += ["--lockfile", path] | |
| 106 | + out = subprocess.run(args, capture_output=True, text=True) | |
| 107 | + # 0: nothing found, 1: vulnerabilities found; anything else is a failure. | |
| 108 | + if out.returncode not in (0, 1): | |
| 109 | + sys.exit(f"osv-scanner failed ({out.returncode}):\n{out.stderr}") | |
| 110 | + return json.loads(out.stdout or "{}") | |
| 111 | + | |
| 112 | + | |
| 113 | +def findings(report): | |
| 114 | + """{(ecosystem, lockfile): [finding]} for the ecosystems handled here.""" | |
| 115 | + found = {} | |
| 116 | + for result in report.get("results", []): | |
| 117 | + lockfile = os.path.relpath(result["source"]["path"]) | |
| 118 | + for pkg in result.get("packages", []): | |
| 119 | + info = pkg["package"] | |
| 120 | + ecosystem = info.get("ecosystem") | |
| 121 | + if ecosystem not in ECOSYSTEMS: | |
| 122 | + continue | |
| 123 | + name, current = info["name"], info["version"] | |
| 124 | + vulns = pkg.get("vulnerabilities", []) | |
| 125 | + fixes = [fixed_for(v, name, current) for v in vulns] | |
| 126 | + ids = sorted({v["id"] for v in vulns}) | |
| 127 | + entry = { | |
| 128 | + "name": name, | |
| 129 | + "current": current, | |
| 130 | + "advisories": ids, | |
| 131 | + "target": max((f for f in fixes if f), key=vkey, default=None), | |
| 132 | + "unfixed": sorted(v["id"] for v, f in zip(vulns, fixes) if not f), | |
| 133 | + } | |
| 134 | + found.setdefault((ecosystem, lockfile), []).append(entry) | |
| 135 | + return found | |
| 136 | + | |
| 137 | + | |
| 138 | +# ── updating ──────────────────────────────────────────────────────────── | |
| 139 | + | |
| 140 | + | |
| 141 | +def run(args, cwd=None, check=True): | |
| 142 | + out = subprocess.run(args, cwd=cwd, capture_output=True, text=True) | |
| 143 | + if check and out.returncode != 0: | |
| 144 | + raise RuntimeError(f"{' '.join(args)} failed:\n{out.stdout}{out.stderr}") | |
| 145 | + return out | |
| 146 | + | |
| 147 | + | |
| 148 | +def lock_versions(ecosystem, lockfile): | |
| 149 | + """{name: [versions]} as the lockfile has them now.""" | |
| 150 | + text = open(lockfile).read() | |
| 151 | + versions = {} | |
| 152 | + if ecosystem == "Hex": | |
| 153 | + for name, version in re.findall(r'^\s*"([^"]+)": \{:hex, :[^,]+, "([^"]+)"', text, re.M): | |
| 154 | + versions.setdefault(name, []).append(version) | |
| 155 | + else: | |
| 156 | + for name, version in re.findall(r'^\[\[package\]\]\nname = "([^"]+)"\nversion = "([^"]+)"', text, re.M): | |
| 157 | + versions.setdefault(name, []).append(version) | |
| 158 | + return versions | |
| 159 | + | |
| 160 | + | |
| 161 | +def cargo_reason(stderr): | |
| 162 | + """cargo's own account of why it couldn't, from its `error:` line on.""" | |
| 163 | + lines = [l.strip() for l in stderr.strip().splitlines()] | |
| 164 | + start = next((i for i, l in enumerate(lines) if l.startswith("error:")), None) | |
| 165 | + if start is None: | |
| 166 | + return "cargo update failed" | |
| 167 | + reason = " ".join(l for l in lines[start : start + 3] if l) | |
| 168 | + return reason.removeprefix("error: ")[:300] | |
| 169 | + | |
| 170 | + | |
| 171 | +def bump(ecosystem, lockfile, entries): | |
| 172 | + """Bump `entries` in `lockfile`; returns (bumped, left, carried) lists, | |
| 173 | + `carried` being what moved along without being asked — what the bumps | |
| 174 | + required.""" | |
| 175 | + root = os.path.dirname(lockfile) or "." | |
| 176 | + before = lock_versions(ecosystem, lockfile) | |
| 177 | + fixable = [e for e in entries if e["target"]] | |
| 178 | + left = [dict(e, reason="no fixed version yet") for e in entries if not e["target"]] | |
| 179 | + if not fixable: | |
| 180 | + return [], left, [] | |
| 181 | + | |
| 182 | + if ecosystem == "Hex": | |
| 183 | + # deps.update takes them as far as mix.exs allows, which may be | |
| 184 | + # past the fix; checked against the target below. | |
| 185 | + out = run(["mix", "deps.update"] + sorted({e["name"] for e in fixable}), cwd=root, check=False) | |
| 186 | + if out.returncode != 0: | |
| 187 | + return [], left + [dict(e, reason=f"mix deps.update failed: {out.stderr.strip()[-200:]}") for e in fixable], [] | |
| 188 | + else: | |
| 189 | + for e in fixable: | |
| 190 | + out = run( | |
| 191 | + ["cargo", "update", "-p", f"{e['name']}@{e['current']}", "--precise", e["target"]], | |
| 192 | + cwd=root, | |
| 193 | + check=False, | |
| 194 | + ) | |
| 195 | + if out.returncode != 0: | |
| 196 | + e["failed"] = cargo_reason(out.stderr) | |
| 197 | + | |
| 198 | + now = lock_versions(ecosystem, lockfile) | |
| 199 | + bumped = [] | |
| 200 | + for e in fixable: | |
| 201 | + versions = now.get(e["name"], []) | |
| 202 | + reached = [v for v in versions if vkey(v) >= vkey(e["target"])] | |
| 203 | + if e.get("failed"): | |
| 204 | + left.append(dict(e, reason=e["failed"])) | |
| 205 | + elif e["current"] not in versions and reached: | |
| 206 | + bumped.append(dict(e, to=max(reached, key=vkey))) | |
| 207 | + else: | |
| 208 | + left.append(dict(e, reason=f"the manifest's constraint doesn't reach {e['target']}")) | |
| 209 | + | |
| 210 | + asked = {e["name"] for e in fixable} | |
| 211 | + carried = [ | |
| 212 | + {"name": name, "current": ", ".join(old), "to": ", ".join(now.get(name, ["(removed)"]))} | |
| 213 | + for name, old in sorted(before.items()) | |
| 214 | + if name not in asked and sorted(old) != sorted(now.get(name, [])) | |
| 215 | + ] | |
| 216 | + return bumped, left, carried | |
| 217 | + | |
| 218 | + | |
| 219 | +# ── the forge ─────────────────────────────────────────────────────────── | |
| 220 | + | |
| 221 | + | |
| 222 | +class Forge: | |
| 223 | + def __init__(self): | |
| 224 | + self.base = os.environ["GITHUB_SERVER_URL"].rstrip("/") + "/api/mobile/v1" | |
| 225 | + self.repo = os.environ["GITHUB_REPOSITORY"] | |
| 226 | + self.token = os.environ["GITHUB_TOKEN"] | |
| 227 | + | |
| 228 | + def call(self, method, path, body=None): | |
| 229 | + req = urllib.request.Request( | |
| 230 | + f"{self.base}/repos/{self.repo}{path}", | |
| 231 | + method=method, | |
| 232 | + data=json.dumps(body).encode() if body is not None else None, | |
| 233 | + headers={ | |
| 234 | + "Authorization": f"Bearer {self.token}", | |
| 235 | + "Accept": "application/json", | |
| 236 | + "Content-Type": "application/json", | |
| 237 | + }, | |
| 238 | + ) | |
| 239 | + try: | |
| 240 | + with urllib.request.urlopen(req, timeout=60) as resp: | |
| 241 | + return json.loads(resp.read() or b"{}") | |
| 242 | + except urllib.error.HTTPError as err: | |
| 243 | + raise RuntimeError(f"{method} {path}: {err.code} {err.read().decode(errors='replace')}") | |
| 244 | + | |
| 245 | + def open_pull(self, branch): | |
| 246 | + pulls = self.call("GET", "/pulls?state=open&limit=100").get("pulls", []) | |
| 247 | + return next((p for p in pulls if p.get("source_ref") == branch), None) | |
| 248 | + | |
| 249 | + | |
| 250 | +# ── pull request text ─────────────────────────────────────────────────── | |
| 251 | + | |
| 252 | + | |
| 253 | +def osv_links(ids): | |
| 254 | + return ", ".join(f"[{i}](https://osv.dev/vulnerability/{i})" for i in ids) | |
| 255 | + | |
| 256 | + | |
| 257 | +def describe(human, bumped, left, carried): | |
| 258 | + lines = [ | |
| 259 | + f"Security updates for {human} dependencies with known vulnerabilities " | |
| 260 | + "([OSV](https://osv.dev)), each to the lowest version that fixes every advisory " | |
| 261 | + "against it, or as far as the manifest allows past that.", | |
| 262 | + "", | |
| 263 | + "| package | from | to | advisories | lockfile |", | |
| 264 | + "|---|---|---|---|---|", | |
| 265 | + ] | |
| 266 | + for b in sorted(bumped, key=lambda b: (b["lockfile"], b["name"])): | |
| 267 | + lines.append( | |
| 268 | + f"| `{b['name']}` | {b['current']} | {b['to']} | {osv_links(b['advisories'])} | `{b['lockfile']}` |" | |
| 269 | + ) | |
| 270 | + if carried: | |
| 271 | + lines += ["", "Also moved, as the above required:", ""] | |
| 272 | + for c in sorted(carried, key=lambda c: (c["lockfile"], c["name"])): | |
| 273 | + lines.append(f"- `{c['name']}` {c['current']} → {c['to']} ({c['lockfile']})") | |
| 274 | + if left: | |
| 275 | + lines += ["", "Left alone:", ""] | |
| 276 | + for l in sorted(left, key=lambda l: (l["lockfile"], l["name"])): | |
| 277 | + lines.append(f"- `{l['name']}` {l['current']} ({l['lockfile']}): {l['reason']}") | |
| 278 | + run_number = os.environ.get("GITHUB_RUN_NUMBER") | |
| 279 | + lines += [ | |
| 280 | + "", | |
| 281 | + "Refreshed in place by the `dependency-updates` workflow" | |
| 282 | + + (f" (run #{run_number})" if run_number else "") | |
| 283 | + + "; it closes this once there's nothing left to fix.", | |
| 284 | + ] | |
| 285 | + return "\n".join(lines) | |
| 286 | + | |
| 287 | + | |
| 288 | +# ── main ──────────────────────────────────────────────────────────────── | |
| 289 | + | |
| 290 | + | |
| 291 | +def main(): | |
| 292 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |
| 293 | + parser.add_argument("--dry-run", action="store_true", help="show what would change; change nothing") | |
| 294 | + parser.add_argument("--base", default=None, help="the branch to update from (default: the current one)") | |
| 295 | + opts = parser.parse_args() | |
| 296 | + | |
| 297 | + base = opts.base or run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() | |
| 298 | + by_lockfile = findings(scan(tracked_lockfiles())) | |
| 299 | + forge = None if opts.dry_run else Forge() | |
| 300 | + | |
| 301 | + if not opts.dry_run: | |
| 302 | + run(["git", "config", "user.name", BOT_NAME]) | |
| 303 | + run(["git", "config", "user.email", BOT_EMAIL]) | |
| 304 | + | |
| 305 | + for ecosystem, (suffix, lockname, human) in ECOSYSTEMS.items(): | |
| 306 | + branch = f"deps/security-{suffix}" | |
| 307 | + lockfiles = sorted(lf for (eco, lf) in by_lockfile if eco == ecosystem) | |
| 308 | + bumped, left, carried = [], [], [] | |
| 309 | + | |
| 310 | + if lockfiles and not opts.dry_run: | |
| 311 | + run(["git", "checkout", "-B", branch, base]) | |
| 312 | + | |
| 313 | + for lockfile in lockfiles: | |
| 314 | + entries = by_lockfile[(ecosystem, lockfile)] | |
| 315 | + for e in entries: | |
| 316 | + e["lockfile"] = lockfile | |
| 317 | + if opts.dry_run: | |
| 318 | + for e in entries: | |
| 319 | + plan = f"→ {e['target']}" if e["target"] else "(no fixed version)" | |
| 320 | + print(f"{human}: {lockfile}: {e['name']} {e['current']} {plan} {', '.join(e['advisories'])}") | |
| 321 | + continue | |
| 322 | + b, l, c = bump(ecosystem, lockfile, entries) | |
| 323 | + bumped += [dict(x, lockfile=lockfile) for x in b] | |
| 324 | + left += [dict(x, lockfile=lockfile) for x in l] | |
| 325 | + carried += [dict(x, lockfile=lockfile) for x in c] | |
| 326 | + | |
| 327 | + if opts.dry_run: | |
| 328 | + continue | |
| 329 | + | |
| 330 | + existing = forge.open_pull(branch) | |
| 331 | + | |
| 332 | + if not bumped: | |
| 333 | + run(["git", "checkout", "--force", base]) | |
| 334 | + if existing: | |
| 335 | + forge.call("POST", f"/pulls/{existing['number']}/comments", | |
| 336 | + {"body": "Nothing left here to fix: closing."}) | |
| 337 | + forge.call("POST", f"/pulls/{existing['number']}/state", {"state": "closed"}) | |
| 338 | + print(f"{human}: nothing to fix; closed #{existing['number']}") | |
| 339 | + else: | |
| 340 | + print(f"{human}: nothing to fix") | |
| 341 | + continue | |
| 342 | + | |
| 343 | + names = ", ".join(sorted({b["name"] for b in bumped})) | |
| 344 | + title = f"Security updates: {human} — {names}" | |
| 345 | + if len(title) > 120: | |
| 346 | + title = f"Security updates: {human} — {len(bumped)} packages" | |
| 347 | + body = describe(human, bumped, left, carried) | |
| 348 | + | |
| 349 | + run(["git", "add"] + lockfiles) | |
| 350 | + run(["git", "commit", "--quiet", "-m", title, "-m", body]) | |
| 351 | + | |
| 352 | + # Push only when the result differs from what the branch has, so | |
| 353 | + # an unchanged week doesn't re-run the pull request's CI. | |
| 354 | + remote = run(["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"], check=False) | |
| 355 | + same = remote.returncode == 0 and run( | |
| 356 | + ["git", "diff", "--quiet", f"origin/{branch}", "HEAD", "--"] + lockfiles, check=False | |
| 357 | + ).returncode == 0 | |
| 358 | + if not same: | |
| 359 | + run(["git", "push", "--force", "origin", f"HEAD:refs/heads/{branch}"]) | |
| 360 | + | |
| 361 | + if existing: | |
| 362 | + forge.call("PATCH", f"/pulls/{existing['number']}", {"title": title, "body": body}) | |
| 363 | + print(f"{human}: refreshed #{existing['number']}" + ("" if not same else " (no new changes)")) | |
| 364 | + else: | |
| 365 | + created = forge.call("POST", "/pulls", {"title": title, "body": body, "head": branch, "base": base}) | |
| 366 | + print(f"{human}: opened #{created['pull']['number']}") | |
| 367 | + | |
| 368 | + run(["git", "checkout", "--force", base]) | |
| 369 | + | |
| 370 | + return 0 | |
| 371 | + | |
| 372 | + | |
| 373 | +if __name__ == "__main__": | |
| 374 | + sys.exit(main()) |
modified
test/git_gud/workflows/pull_request_dispatch_test.exs
+31
−0
@@ -250,6 +250,37 @@ defmodule GitGud.Workflows.PullRequestDispatchTest do
| 250 | 250 | assert [_opened] = pr_runs(repo) |
| 251 | 251 | end |
| 252 | 252 | |
| 253 | + test "a job refreshing a pull request a job opened does start its synchronize", %{ | |
| 254 | + repo: repo, | |
| 255 | + user: user | |
| 256 | + } do | |
| 257 | + files = %{@wf => workflow(@on_both), "lib/a" => "a\n"} | |
| 258 | + {_main, feature} = branches!(repo, %{@wf => workflow(@on_both)}, %{"lib/a" => "a\n"}) | |
| 259 | + pr = open_pr!(repo, user) | |
| 260 | + | |
| 261 | + # As if a job had opened it: the run it came from. | |
| 262 | + opener = | |
| 263 | + %WorkflowRun{repository_id: repo.id} | |
| 264 | + |> WorkflowRun.create_changeset(%{ | |
| 265 | + number: 999, | |
| 266 | + workflow_path: @wf, | |
| 267 | + trigger: "schedule", | |
| 268 | + head_sha: :crypto.strong_rand_bytes(20) | |
| 269 | + }) | |
| 270 | + |> Repo.insert!() | |
| 271 | + | |
| 272 | + Repo.update!(Ecto.Changeset.change(pr, workflow_run_id: opener.id)) | |
| 273 | + | |
| 274 | + push_feature!(repo, user, Map.put(files, "lib/b", "b\n"), feature, job_token: true) | |
| 275 | + | |
| 276 | + assert [_opened, synchronize] = pr_runs(repo) | |
| 277 | + assert synchronize.pull_request_number == pr.number | |
| 278 | + # Still no push run, and the refreshed PR's run is one hop: it can't | |
| 279 | + # open anything itself. | |
| 280 | + assert runs(repo, "push") == [] | |
| 281 | + assert GitGud.Workflows.opened_by_job?(synchronize) | |
| 282 | + end | |
| 283 | + | |
| 253 | 284 | test "a push made with a job token starts nothing, but still moves the PR", %{ |
| 254 | 285 | repo: repo, |
| 255 | 286 | user: user |
Parents: d3fedc5