Security update pull requests from CI (B)

9c7f1f9 · Gabriel Morell · 2026-10-22 21:17

19 files +520 -3
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