CI: green for what a pull request fixes, and a test run that fits the runner

c91f312 · Gabriel Morell · 2026-10-22 22:42

13 files +130 -12
Message
{commit_body(@commit)}

Files changed

modified .forgejo/workflows/audit.yml
+18 −6
@@ -6,9 +6,11 @@ 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.
9 +# On a pull request it compares the lockfiles the pull request changes
10 +# with the same files on the base branch: it fails only on a
11 +# vulnerability the pull request brings in, and says what it fixed and
12 +# what's still there (scripts/osv-diff). A security update PR goes green
13 +# for what it fixes, not red for what it couldn't.
12 14 #
13 15 # The Android app isn't covered: osv-scanner reads Gradle lockfiles,
14 16 # and the app has a version catalog (gradle/libs.versions.toml) only.
@@ -60,6 +62,16 @@ jobs:
60 62 echo "No lockfile changed."
61 63 exit 0
62 64 fi
63 − echo "$changed"
64 − set -- $(printf -- '--lockfile %s ' $changed)
65 − /tmp/osv-scanner scan source "$@"
65 + mkdir -p /tmp/base
66 + for f in $changed; do
67 + mkdir -p "/tmp/base/$(dirname "$f")"
68 + git show "origin/$BASE:$f" > "/tmp/base/$f" 2>/dev/null || true
69 + done
70 + # 0 and 1 (found some) are both answers; with nothing to scan, none.
71 + scan() {
72 + if [ $# -eq 0 ]; then echo '{}'; return; fi
73 + /tmp/osv-scanner scan source --format json $(printf -- '--lockfile %s ' "$@") || [ $? -eq 1 ]
74 + }
75 + (cd /tmp/base && scan $(for f in $changed; do [ -s "$f" ] && echo "$f"; done)) > /tmp/base.json
76 + scan $changed > /tmp/head.json
77 + python3 scripts/osv-diff /tmp/base.json /tmp/head.json
modified .forgejo/workflows/checks.yml
+7 −0
@@ -28,6 +28,13 @@ jobs:
28 28 MIX_ENV: test
29 29 GITGUD_TEST_DB_HOST: postgres
30 30 GITGUD_TEST_DB_PORT: "5432"
31 + # Postgres takes 100 connections; the runner has cores for more.
32 + GITGUD_TEST_DB_POOL_SIZE: "40"
33 + # A fresh container has no git identity, and some tests commit.
34 + GIT_AUTHOR_NAME: checks
35 + GIT_AUTHOR_EMAIL: checks@bots.invalid
36 + GIT_COMMITTER_NAME: checks
37 + GIT_COMMITTER_EMAIL: checks@bots.invalid
31 38 steps:
32 39 - uses: actions/checkout@v4
33 40
modified config/test.exs
+6 −1
@@ -16,7 +16,12 @@ config :git_gud, GitGud.Repo,
16 16 hostname: System.get_env("GITGUD_TEST_DB_HOST", "localhost"),
17 17 database: "git_gud_test#{System.get_env("MIX_TEST_PARTITION")}",
18 18 pool: Ecto.Adapters.SQL.Sandbox,
19 − pool_size: System.schedulers_online() * 2,
19 + # Twice the cores unless said otherwise: a many-core CI runner would
20 + # open more connections than its Postgres allows.
21 + pool_size:
22 + String.to_integer(
23 + System.get_env("GITGUD_TEST_DB_POOL_SIZE", "#{System.schedulers_online() * 2}")
24 + ),
20 25 port: String.to_integer(System.get_env("GITGUD_TEST_DB_PORT", "54328"))
21 26
22 27 # We don't run a server during test. If one is required,
modified scripts/dependency-update
+4 −3
@@ -349,11 +349,12 @@ def main():
349 349 run(["git", "add"] + lockfiles)
350 350 run(["git", "commit", "--quiet", "-m", title, "-m", body])
351 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.
352 + # Push only when the result differs from what the branch has — the
353 + # bumps, or the base it sits on — so an unchanged week doesn't
354 + # re-run the pull request's CI, but a moved base does move it.
354 355 remote = run(["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"], check=False)
355 356 same = remote.returncode == 0 and run(
356 − ["git", "diff", "--quiet", f"origin/{branch}", "HEAD", "--"] + lockfiles, check=False
357 + ["git", "diff", "--quiet", f"origin/{branch}", "HEAD"], check=False
357 358 ).returncode == 0
358 359 if not same:
359 360 run(["git", "push", "--force", "origin", f"HEAD:refs/heads/{branch}"])
added scripts/osv-diff
+77 −0
@@ -0,0 +1,77 @@
1 +#!/usr/bin/env python3
2 +"""Compare two osv-scanner JSON reports: a pull request's lockfiles
3 +against the same files on its base branch.
4 +
5 + scripts/osv-diff BASE.json HEAD.json [BASE_ROOT]
6 +
7 +Prints what the pull request fixed, what it brings in, and what's still
8 +there; exits 1 only if it brings something in. BASE_ROOT (default
9 +/tmp/base) is where the base branch's copies were written, stripped so
10 +the two reports' paths line up. Used by .forgejo/workflows/audit.yml.
11 +"""
12 +
13 +import json
14 +import os
15 +import sys
16 +
17 +
18 +def advisories(report, root):
19 + """{(lockfile, package, version): {advisory ids}} from a report."""
20 + found = {}
21 + for result in report.get("results", []):
22 + path = result["source"]["path"]
23 + path = os.path.relpath(path, root) if path.startswith(root + os.sep) else os.path.relpath(path)
24 + for pkg in result.get("packages", []):
25 + info = pkg["package"]
26 + ids = {v["id"] for v in pkg.get("vulnerabilities", [])}
27 + if ids:
28 + found[(path, info["name"], info["version"])] = ids
29 + return found
30 +
31 +
32 +def by_package(found):
33 + """{(lockfile, package): {advisory ids}}, versions folded away."""
34 + out = {}
35 + for (path, name, _version), ids in found.items():
36 + out.setdefault((path, name), set()).update(ids)
37 + return out
38 +
39 +
40 +def load(path):
41 + with open(path) as f:
42 + text = f.read().strip()
43 + return json.loads(text) if text else {}
44 +
45 +
46 +def main(argv):
47 + if len(argv) < 3:
48 + sys.exit(__doc__)
49 + root = argv[3] if len(argv) > 3 else "/tmp/base"
50 + base = by_package(advisories(load(argv[1]), root))
51 + head = by_package(advisories(load(argv[2]), root))
52 +
53 + fixed, added, still = [], [], []
54 + for key in sorted(set(base) | set(head)):
55 + before, after = base.get(key, set()), head.get(key, set())
56 + path, name = key
57 + if before - after:
58 + fixed.append(f"{name} ({path}): {', '.join(sorted(before - after))}")
59 + if after - before:
60 + added.append(f"{name} ({path}): {', '.join(sorted(after - before))}")
61 + if after & before:
62 + still.append(f"{name} ({path}): {', '.join(sorted(after & before))}")
63 +
64 + for title, lines in (("Fixed", fixed), ("Brought in", added), ("Still there, as before", still)):
65 + print(f"{title}: {len(lines)}")
66 + for line in lines:
67 + print(f" {line}")
68 +
69 + if added:
70 + print("\nThis pull request brings in known vulnerabilities.")
71 + return 1
72 + print(f"\nNothing new. {len(fixed)} fixed, {len(still)} still open from before.")
73 + return 0
74 +
75 +
76 +if __name__ == "__main__":
77 + sys.exit(main(sys.argv))
modified test/support/workflow_git_helpers.ex
+16 −1
@@ -7,6 +7,13 @@ defmodule GitGud.WorkflowGitHelpers do
7 7 alias GitGud.Repositories.Repository
8 8 alias GitGud.Repositories.Workers.CommitBackfill
9 9
10 + @identity [
11 + {"GIT_AUTHOR_NAME", "Test"},
12 + {"GIT_AUTHOR_EMAIL", "test@example.com"},
13 + {"GIT_COMMITTER_NAME", "Test"},
14 + {"GIT_COMMITTER_EMAIL", "test@example.com"}
15 + ]
16 +
10 17 @doc "A commit holding exactly `files` (path => contents) on top of `parents`."
11 18 def commit!(%Repository{disk_path: path}, files, parents, message \\ "c") do
12 19 index = Path.join(System.tmp_dir!(), "gitidx-#{System.unique_integer([:positive])}")
@@ -19,7 +26,15 @@ defmodule GitGud.WorkflowGitHelpers do
19 26 end
20 27
21 28 tree = git!(path, ["write-tree"], nil, env)
22 − git!(path, ["commit-tree", tree | Enum.flat_map(parents, &["-p", &1])] ++ ["-m", message])
29 +
30 + # Who wrote it, given here rather than taken from a git config a CI
31 + # container doesn't have.
32 + git!(
33 + path,
34 + ["commit-tree", tree | Enum.flat_map(parents, &["-p", &1])] ++ ["-m", message],
35 + nil,
36 + @identity
37 + )
23 38 after
24 39 File.rm(index)
25 40 end
modified test/test_helper.exs
+2 −1
@@ -3,5 +3,6 @@
3 3 # backend tolerated non-empty dirs; gix doesn't.
4 4 _ = File.rm_rf(GitGud.Repositories.Storage.base_path())
5 5
6 −ExUnit.start()
6 +# `built_apk` needs ./gradlew :app:assembleDebug first: `mix test --include built_apk`.
7 +ExUnit.start(exclude: [:built_apk])
7 8 Ecto.Adapters.SQL.Sandbox.mode(GitGud.Repo, :manual)

Parents: 9892e50