#!/usr/bin/env python # Verify all commits in a Git repository. # This is a script for GitHub/Forgejo Actions and does not work properly without the necessary environment variables set. # Required dependencies: GitPython, requests import logging import git from os import environ as env, chdir from pathlib import Path from tempfile import NamedTemporaryFile import requests log = logging.getLogger(__name__) is_ci = "CI" in env repo_name = env["GITHUB_REPOSITORY"] server = env["GITHUB_SERVER_URL"] action_ref = env["GITHUB_REF"] def collect_user_dirs(): return ( dir for dir in Path(".").glob("*") if dir.is_dir() and not dir.name.startswith(".") ) def last_commit_for(dir: Path, ref: git.Reference): """Returns the Git commit signature for the last commit on this path.""" last_commit_hash = str(ref.repo.git.rev_list("--max-count=1", ref, dir)) return ref.repo.commit(last_commit_hash) def keylist_to_principals(keyfile_text: str, email: str) -> str: # trailing newline, otherwise git may get confused and reject the key return "\n".join( f"{email} {public_key}" for public_key in keyfile_text.splitlines() ) + "\n" def get_forgejo_keys(username: str) -> str: response = requests.get(f"{server}/{username}.keys") if not response.ok: log.error(f"Server error: {response.status_code} {response.reason}") raise Exception(f"Couldn’t retrieve keylist for user {username}.") return response.text def verify_dir(dir: Path, ref: git.Reference): username = dir.name keyfile = dir / "keys" if not keyfile.exists(): raise Exception("Missing keyfile") commit = last_commit_for(dir, ref) log.debug(f"Found last commit: {commit.name_rev}") # ignore author casing if commit.author.name.lower() != username.lower(): raise Exception( f"Commit author {commit.author.name} is not the owner of this directory." ) signature = commit.gpgsig if signature is None or len(signature) == 0: raise Exception( f"Last commit {commit.name_rev} was not signed (committer {commit.author.name})." ) email = commit.author.email if email is None: raise Exception( f"Commit {commit.name_rev} does not have an email address attached (committer {commit.author.name})." ) log.info( f"Last commit {commit.name_rev} was signed by the correct user {commit.author}" ) local_keys = keyfile.read_text() remote_keys = get_forgejo_keys(username) log.debug(f"Got remote keys:\n{remote_keys}") with NamedTemporaryFile(mode="+w") as temp_keyfile: # First, configure git to use our custom-made principals file with ref.repo.config_writer("repository") as config: config.set_value("gpg.ssh", "allowedSignersFile", temp_keyfile.name) temp_keyfile_contents = keylist_to_principals(remote_keys, email) temp_keyfile.write(temp_keyfile_contents) temp_keyfile.flush() log.debug(f"temp keyfile:\n{Path(temp_keyfile.name).read_text()}") # Check whether one of the user keys signed this commit. # throws an exception automatically if verification fails, nothing else to do ref.repo.git.verify_commit("--raw", commit.hexsha) # BTW: For ssh-keygen -Y verify, the namespace is really just “git”. No, this is not documented anywhere. # Oh I absolutely went digging for this in the git source code, though it wasn’t hard to find. # See https://git.kernel.org/pub/scm/git/git.git/tree/gpg-interface.c?id=5f8f7081f7761acdf83d0a4c6819fe3d724f01d7#n559 # Verify that the key is also in the local keylist. temp_keyfile_contents = keylist_to_principals(local_keys, email) temp_keyfile.write(temp_keyfile_contents) temp_keyfile.flush() ref.repo.git.verify_commit("--raw", commit.hexsha) def current_ref(repo: git.Repo) -> git.Reference: log.debug(f"{repo.references}") # some of this logic stolen from https://code.forgejo.org/actions/checkout/src/branch/main/src/ref-helper.ts - the ref names github provides are beyond fucked global action_ref if (action_ref.startswith('refs/heads/')): branch = action_ref.removeprefix('refs/heads/') action_ref = branch # refs/pull/ elif (action_ref.startswith('refs/pull/')): branch = action_ref.removeprefix('refs/pull/') action_ref = f'refs/remotes/pull/{branch}' for ref in repo.refs: if ref.name == action_ref or ref.path == action_ref: return ref raise Exception(f"No ref named {action_ref} found") def main(): logging.basicConfig( style="{", format="[{levelname:8}] {message}", level=logging.INFO if is_ci else logging.DEBUG, ) chdir(env.get("GITHUB_WORKSPACE", default=".")) if is_ci: log.info("Verification script running in CI.") log.debug(f"{server} / {repo_name}") log.debug(f"Repo dir: {Path.cwd().resolve()}") repo = git.Repo(Path.cwd()) ref = current_ref(repo) dirs = collect_user_dirs() errors = 0 successes = 0 for dir in dirs: try: log.info(f"------ Verifying {dir.name} ...") verify_dir(dir, ref) successes += 1 except Exception as e: log.error(f"Verification failure for user {dir.name}: {e}") errors += 1 if errors > 0: log.error("At least one user failed verification. See log for details.") exit(errors) else: log.info(f"All users passed verification. Checked {successes} directories.") if __name__ == "__main__": main()