[packages/pld-gitolite] Rel 4; add check-spec-sources pre-receive hook
arekm
arekm at pld-linux.org
Sun Jul 19 10:18:55 CEST 2026
commit ff7b65c41a1eae7fb82dafdab53b5a007a9961f5
Author: Arkadiusz Miśkiewicz <arekm at maven.pl>
Date: Sun Jul 19 10:08:48 2026 +0200
Rel 4; add check-spec-sources pre-receive hook
check-spec-sources | 502 +++++++++++++++++++++++++++++++++++++++++++++++++++++
pld-gitolite.spec | 12 +-
pre-receive | 45 +++++
3 files changed, 558 insertions(+), 1 deletion(-)
---
diff --git a/pld-gitolite.spec b/pld-gitolite.spec
index 696af19..b545fd6 100644
--- a/pld-gitolite.spec
+++ b/pld-gitolite.spec
@@ -3,7 +3,7 @@ Summary: Gitolite setup used by PLD
Summary(pl.UTF-8): Konfiguracja Gitolite wykorzystywana przez PLD
Name: pld-gitolite
Version: 0.13.4
-Release: 3
+Release: 4
License: GPL v2
Group: Development/Building
Source0: https://github.com/draenog/gitolite-scripts/tarball/v%{version}/gitolite-scripts.tar.gz
@@ -14,6 +14,8 @@ Source3: git.conf
Source4: gitweb.conf
Source5: pld-developers
Source6: crontab
+Source7: pre-receive
+Source8: check-spec-sources
Patch0: python.patch
BuildRequires: rpm-pythonprov
BuildRequires: rpmbuild(macros) >= 1.202
@@ -22,6 +24,7 @@ Requires: git-core-slug
Requires: gitolite
Requires: perl-RPC-XML
Requires: python3-requests
+Requires: python3-rpm
Provides: group(%{gituser})
Provides: user(%{gituser})
Requires(postun): /usr/sbin/groupdel
@@ -56,6 +59,9 @@ cp -p %{SOURCE1} %{SOURCE5} $RPM_BUILD_ROOT/home/services/%{gituser}/.gitolite/c
cp -p %{SOURCE2} $RPM_BUILD_ROOT/home/services/%{gituser}/.gitolite.rc
cp -p %{SOURCE3} $RPM_BUILD_ROOT/home/services/%{gituser}/.gitconfig
cp -a hooks/* $RPM_BUILD_ROOT/home/services/%{gituser}/.gitolite/hooks/common
+install -d $RPM_BUILD_ROOT/home/services/%{gituser}/.gitolite/hooks/common/{pre-receive.d,pre-receive.python.d}
+cp -p %{SOURCE7} $RPM_BUILD_ROOT/home/services/%{gituser}/.gitolite/hooks/common/pre-receive
+cp -p %{SOURCE8} $RPM_BUILD_ROOT/home/services/%{gituser}/.gitolite/hooks/common/pre-receive.d/check-spec-sources
cp -a adc $RPM_BUILD_ROOT/home/services/%{gituser}
cp -a bin/* $RPM_BUILD_ROOT/home/services/%{gituser}/bin
@@ -100,6 +106,10 @@ fi
%dir /home/services/%{gituser}/.gitolite/hooks
%dir /home/services/%{gituser}/.gitolite/hooks/common
%attr(744,%{gituser},%{gituser}) /home/services/%{gituser}/.gitolite/hooks/common/update.secondary
+%attr(744,%{gituser},%{gituser}) /home/services/%{gituser}/.gitolite/hooks/common/pre-receive
+%dir /home/services/%{gituser}/.gitolite/hooks/common/pre-receive.d
+%attr(744,%{gituser},%{gituser}) /home/services/%{gituser}/.gitolite/hooks/common/pre-receive.d/check-spec-sources
+%dir /home/services/%{gituser}/.gitolite/hooks/common/pre-receive.python.d
%attr(744,%{gituser},%{gituser}) /home/services/%{gituser}/.gitolite/hooks/common/post-receive
%dir /home/services/%{gituser}/.gitolite/hooks/common/post-receive.d
%attr(744,%{gituser},%{gituser}) /home/services/%{gituser}/.gitolite/hooks/common/post-receive.d/setdescription.sh
diff --git a/check-spec-sources b/check-spec-sources
new file mode 100755
index 0000000..fc08f06
--- /dev/null
+++ b/check-spec-sources
@@ -0,0 +1,502 @@
+#!/usr/bin/python3 -u
+"""
+Gitolite server-side hook that enforces consistency between
+Source/Patch references in .spec files and actual files in the repository.
+
+Rules enforced:
+ - If a Source/Patch is ADDED (or its filename changed) in the spec,
+ the referenced file must be present in the repository or in the
+ dropin directory (/var/lib/dropin/dropin). URL-based entries are
+ exempt (they are fetched at build time). Push is rejected otherwise.
+ - If a Source/Patch is REMOVED from the spec, the referenced file
+ must also be removed in the same commit (but only if the file
+ actually existed in the repo before).
+ - If a file is deleted that is still referenced as Source/Patch
+ in the spec, the push is rejected.
+
+Exceptions (skipped):
+ - NoSource / NoPatch entries
+ - URL-based sources/patches (http://, https://, ftp://) — fetched
+ at build time, not stored in git
+
+Uses the Python rpm library for spec parsing, which handles all macro
+expansion natively (including nested macros, conditionals, %{lua:}, etc.).
+
+Shipped by the pld-gitolite package as a pre-receive.d drop-in:
+ .gitolite/hooks/common/pre-receive.d/check-spec-sources
+The pre-receive dispatcher feeds it stdin and propagates its exit status.
+
+Reads "oldrev newrev refname" lines from stdin (pre-receive interface).
+Exit 0 = accept, exit 1 = reject.
+"""
+
+import os
+import re
+import subprocess
+import sys
+import tempfile
+
+import rpm
+
+# Set to True to report problems without rejecting the push.
+DRY_RUN = False
+
+# Set to True to log all hook activity (for debugging).
+VERBOSE = True
+
+ZERO_SHA = "0" * 40
+
+# Directory where files can be placed for upload to distfiles.pld-linux.org.
+# Non-URL sources/patches not in git are accepted if found here.
+DROPIN_DIR = "/var/lib/dropin/dropin"
+
+
+def git(*args):
+ """Run a git command, return stdout or None on failure."""
+ result = subprocess.run(
+ ["git"] + list(args),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ if result.returncode != 0:
+ return None
+ return result.stdout
+
+
+def git_show(rev, path):
+ """Get file content at a given revision."""
+ return git("show", f"{rev}:{path}")
+
+
+def git_ls_tree_names(rev):
+ """Return set of file paths in a tree."""
+ output = git("ls-tree", "-r", "--name-only", rev)
+ if not output or not output.strip():
+ return set()
+ return set(output.strip().split("\n"))
+
+
+def git_diff_name_status(old_rev, new_rev):
+ """Return list of (status_char, filepath) tuples for changes between revisions."""
+ if old_rev == ZERO_SHA:
+ output = git(
+ "diff-tree", "--no-commit-id", "-r", "--name-status", "--root", new_rev
+ )
+ else:
+ output = git("diff", "--name-status", old_rev, new_rev)
+ if not output or not output.strip():
+ return []
+ result = []
+ for line in output.strip().split("\n"):
+ if not line:
+ continue
+ parts = line.split("\t")
+ if len(parts) >= 2:
+ status = parts[0][0]
+ filepath = parts[-1] # for renames/copies, take the destination
+ result.append((status, filepath))
+ return result
+
+
+# ---------------------------------------------------------------------------
+# RPM spec parsing via python-rpm
+# ---------------------------------------------------------------------------
+
+
+def is_url(value):
+ """Return True if value looks like a URL."""
+ return bool(re.match(r"(https?|ftp)://", value, re.IGNORECASE))
+
+
+def basename_of(path):
+ """Return the filename portion of a path or URL."""
+ return path.rsplit("/", 1)[-1] if "/" in path else path
+
+
+def add_parametric_macro(name):
+ """Define a parametric macro that consumes its arguments and expands to nothing."""
+ rpm.expandMacro(f"%define {name}() %{{nil}}")
+
+
+def setup_builtin_macros():
+ """
+ Pre-define macros that rpm-specdump defines to allow parsing specs
+ outside a full build environment. These cover common macros from
+ rpm-build and PLD-specific macro packages that may not be available
+ on the git server.
+ """
+ simple_macros = {
+ "patch": "%{nil}",
+ "include": "%{nil}",
+ }
+ # PLD-specific macros that expand to RPM tags (BuildRequires,
+ # Requires, etc.). Defined as parametric so they consume their
+ # arguments; otherwise arguments appear as orphaned text causing
+ # "Unknown tag" parse errors.
+ parametric_macros = [
+ # macros.java
+ "buildrequires_jdk",
+ # macros.python
+ "pyrequires_eq",
+ # macros.php
+ "__php_api_requires",
+ "requires_php_extension",
+ "requires_zend_extension",
+ "requires_php_pdo_module",
+ # macros.kernel
+ "buildrequires_kernel",
+ "requires_releq_kernel",
+ "requires_releq_kernel_up",
+ "requires_releq_kernel_smp",
+ # macros.xorg
+ "__xorg_abi_requires_ge",
+ "__xorg_abi_requires_lt",
+ "requires_xorg_xserver_extension",
+ "requires_xorg_xserver_xinput",
+ "requires_xorg_xserver_font",
+ "requires_xorg_xserver_videodrv",
+ # macros.erlang
+ "erlang_requires",
+ # macros.rust
+ "rust_req",
+ # macros.systemd
+ "systemd_requires",
+ "systemd_ordering",
+ ]
+ defined = []
+ for name, value in simple_macros.items():
+ # Only define if not already present.
+ if not rpm.expandMacro(f"%{{?{name}}}"):
+ rpm.addMacro(name, value)
+ defined.append(name)
+ for name in parametric_macros:
+ add_parametric_macro(name)
+ defined.append(name)
+ return defined
+
+
+def parse_spec_from_content(spec_content):
+ """
+ Parse a spec file from its text content using the rpm library.
+
+ Writes content to a temp file, parses with rpm.spec(), and returns
+ a dict mapping (kind, num) -> filename for each source/patch entry.
+
+ kind is 'source' or 'patch'.
+ Entries marked NoSource/NoPatch are excluded.
+ URL entries are excluded.
+
+ Handling of unknown macros:
+ 1. Pre-define standard build macros (like rpm-specdump does) to
+ handle specs that use %patch, %include, etc. as directives.
+ 2. If parsing still fails on "Unknown tag: %foo" (custom macros
+ like %buildrequires_jdk that expand to BuildRequires in the
+ build env), define the offending macro as %{nil} and retry
+ up to MAX_RETRIES times.
+
+ Returns:
+ dict of {('source'|'patch', int): str} — the filename (basename)
+ """
+ MAX_RETRIES = 20
+ entries = {}
+ added_macros = []
+
+ builtin_macros = setup_builtin_macros()
+
+ fd, tmppath = tempfile.mkstemp(suffix=".spec")
+ try:
+ with os.fdopen(fd, "w") as f:
+ f.write(spec_content)
+
+ for attempt in range(MAX_RETRIES + 1):
+ rpm.setVerbosity(rpm.RPMLOG_ERR)
+ # Capture rpm error output to detect unknown tags.
+ old_stderr = os.dup(2)
+ stderr_r, stderr_w = os.pipe()
+ os.dup2(stderr_w, 2)
+ os.close(stderr_w)
+ try:
+ parsed = rpm.spec(tmppath)
+ except Exception:
+ parsed = None
+ finally:
+ os.dup2(old_stderr, 2)
+ os.close(old_stderr)
+ rpm.setVerbosity(rpm.RPMLOG_WARNING)
+
+ # Read captured stderr.
+ with os.fdopen(stderr_r, "r") as f:
+ stderr_output = f.read()
+
+ if parsed is not None:
+ break # success
+
+ # Look for "Unknown tag: %macroname" in stderr and define
+ # the missing macro as parametric %{nil} so the line
+ # (including any arguments) becomes empty.
+ m = re.search(r"Unknown tag:\s*%(\w+)", stderr_output)
+ if m and attempt < MAX_RETRIES:
+ macro_name = m.group(1)
+ add_parametric_macro(macro_name)
+ added_macros.append(macro_name)
+ log(f" defined missing macro %{{{macro_name}}} as %{{nil}}")
+ continue
+ else:
+ msg = stderr_output.strip() or "can't parse specfile"
+ print(
+ f"WARNING: Could not parse spec with rpm library: {msg}",
+ file=sys.stderr,
+ )
+ return None
+
+ for fullpath, num, flags in parsed.sources:
+ is_source = bool(flags & rpm.RPMBUILD_ISSOURCE)
+ is_patch = bool(flags & rpm.RPMBUILD_ISPATCH)
+ is_no = bool(flags & rpm.RPMBUILD_ISNO)
+
+ if is_no:
+ continue
+
+ if is_url(fullpath):
+ continue
+
+ kind = "source" if is_source else "patch" if is_patch else None
+ if kind is None:
+ continue
+
+ filename = basename_of(fullpath)
+ entries[(kind, num)] = filename
+
+ except Exception as e:
+ print(
+ f"WARNING: Could not parse spec with rpm library: {e}",
+ file=sys.stderr,
+ )
+ return None
+ finally:
+ # Clean up any macros we added so they don't leak to other specs.
+ for macro_name in added_macros + builtin_macros:
+ rpm.delMacro(macro_name)
+ os.unlink(tmppath)
+
+ return entries
+
+
+def find_spec_files(file_set):
+ """Return list of .spec files from a set of paths."""
+ return sorted(f for f in file_set if f.endswith(".spec"))
+
+
+# ---------------------------------------------------------------------------
+# Main logic
+# ---------------------------------------------------------------------------
+
+
+def check_ref(refname, old_rev, new_rev):
+ """
+ Validate a single ref update. Returns list of error strings (empty = OK).
+
+ Two phases:
+
+ Phase 1 — spec file was modified:
+ Compare old vs new spec to find added/changed/removed Source/Patch
+ entries. Verify that corresponding files were added or removed in
+ the same push.
+
+ Phase 2 — files were deleted but spec was NOT modified:
+ Parse the (unchanged) spec from the new tree and check whether any
+ of the deleted files are still referenced. This catches the case
+ where someone deletes a patch file but forgets to update the spec.
+ Skipped for specs already checked in phase 1 (they cover this).
+ If there is no spec file in the repo at all, this is a no-op.
+ """
+ errors = []
+
+ if new_rev == ZERO_SHA:
+ return errors # branch deletion, nothing to check
+
+ if refname.startswith("refs/tags/"):
+ return errors # tag push, nothing to check
+
+ changes = git_diff_name_status(old_rev, new_rev)
+ if not changes:
+ return errors
+
+ changed_files = {filepath for _, filepath in changes}
+ deleted_files = {filepath for status, filepath in changes if status == "D"}
+
+ new_tree_files = git_ls_tree_names(new_rev)
+ old_tree_files = git_ls_tree_names(old_rev) if old_rev != ZERO_SHA else set()
+
+ # Phase 1: check specs that were modified in this push.
+ changed_spec_files = find_spec_files(changed_files)
+ if changed_spec_files:
+ for spec_file in changed_spec_files:
+ log(f" checking {spec_file}")
+ spec_errors = check_spec(
+ spec_file, old_rev, new_rev, old_tree_files, new_tree_files
+ )
+ errors.extend(spec_errors)
+ else:
+ log(f" no spec files changed")
+
+ # Phase 2: if files were deleted, check that they aren't still
+ # referenced by an unchanged spec.
+ if deleted_files:
+ all_specs = find_spec_files(new_tree_files)
+ for spec_file in all_specs:
+ if spec_file in changed_spec_files:
+ continue # already fully validated in phase 1
+ spec_content = git_show(new_rev, spec_file)
+ if spec_content is None:
+ continue
+ entries = parse_spec_from_content(spec_content)
+ if entries is None:
+ continue
+ referenced_files = set(entries.values())
+ for deleted in deleted_files:
+ if deleted in referenced_files:
+ key = next(k for k, v in entries.items() if v == deleted)
+ kind, num = key
+ label = f"Source{num}" if kind == "source" else f"Patch{num}"
+ errors.append(
+ f"{label}: '{deleted}' is still referenced in "
+ f"{spec_file} but was deleted. "
+ f"Update the spec file in the same commit."
+ )
+
+ return errors
+
+
+def check_spec(spec_file, old_rev, new_rev, old_tree_files, new_tree_files):
+ """
+ Check a modified spec file for source/patch consistency.
+
+ Compares the old and new versions of the spec to find:
+ - Added/changed entries: the referenced file must exist in the new tree
+ or in the dropin directory. URL entries are already excluded by
+ parse_spec_from_content().
+ - Removed entries: the old file must be deleted (but only if it existed
+ in the old tree — a source that was never committed can be freely
+ removed from the spec without deleting anything).
+ """
+ errors = []
+
+ new_spec_content = git_show(new_rev, spec_file)
+ if new_spec_content is None:
+ return errors # spec was deleted, nothing to enforce
+
+ new_entries = parse_spec_from_content(new_spec_content)
+ if new_entries is None:
+ return errors # rpm couldn't parse it, let push through
+
+ old_entries = {}
+ if old_rev != ZERO_SHA:
+ old_spec_content = git_show(old_rev, spec_file)
+ if old_spec_content:
+ old_entries = parse_spec_from_content(old_spec_content)
+ if old_entries is None:
+ old_entries = {}
+
+ # Added/changed: new entry not matching old -> file must exist in the
+ # new tree or in the dropin directory (for upload to distfiles).
+ # URL entries are already excluded by parse_spec_from_content().
+ for (kind, num), filename in new_entries.items():
+ old_filename = old_entries.get((kind, num))
+ if old_filename == filename:
+ continue # unchanged
+
+ label = f"Source{num}" if kind == "source" else f"Patch{num}"
+ if filename not in new_tree_files:
+ dropin_path = os.path.join(DROPIN_DIR, filename)
+ if os.path.exists(dropin_path):
+ log(f" {label}: '{filename}' found in dropin directory")
+ continue
+ errors.append(
+ f"{label}: '{filename}' added/changed in {spec_file} "
+ f"but the file is not present in the repository "
+ f"or in {DROPIN_DIR}."
+ )
+
+ # Removed: old entry gone from new spec -> file must be deleted
+ # (but only if it actually existed in the old tree).
+ new_filenames = set(new_entries.values())
+ for (kind, num), filename in old_entries.items():
+ new_filename = new_entries.get((kind, num))
+ if new_filename == filename:
+ continue # still present with same filename
+
+ # The file may have been renumbered (e.g. Patch2 -> Patch1)
+ # but is still referenced under a different number.
+ if filename in new_filenames:
+ continue
+
+ if filename not in old_tree_files:
+ continue # was never committed, nothing to delete
+
+ label = f"Source{num}" if kind == "source" else f"Patch{num}"
+ if filename in new_tree_files:
+ errors.append(
+ f"{label}: '{filename}' removed from {spec_file} "
+ f"but the file still exists in the repository. "
+ f"Remove the file in the same commit."
+ )
+
+ return errors
+
+
+def log(msg):
+ """Print a message to stderr if VERBOSE is enabled."""
+ if VERBOSE:
+ print(f"check-spec-sources: {msg}", file=sys.stderr)
+
+
+def main():
+ log("hook started")
+
+ # pre-receive hook: read "oldrev newrev refname" lines from stdin
+ refs = []
+ for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+ parts = line.split()
+ if len(parts) >= 3:
+ refs.append((parts[2], parts[0], parts[1]))
+
+ if not refs:
+ log("no refs received, exiting")
+ sys.exit(0)
+
+ all_errors = []
+ for refname, old_rev, new_rev in refs:
+ log(f"checking {refname} {old_rev[:12]}..{new_rev[:12]}")
+ ref_errors = check_ref(refname, old_rev, new_rev)
+ if ref_errors:
+ all_errors.extend(ref_errors)
+ else:
+ log(f" OK")
+
+ log(f"done, {len(all_errors)} error(s)")
+
+ if all_errors:
+ mode = "DRY-RUN" if DRY_RUN else "PUSH REJECTED"
+ print("", file=sys.stderr)
+ print("=" * 70, file=sys.stderr)
+ print(
+ f" {mode}: Source/Patch consistency check failed",
+ file=sys.stderr,
+ )
+ print("=" * 70, file=sys.stderr)
+ for err in all_errors:
+ print(f" * {err}", file=sys.stderr)
+ print("=" * 70, file=sys.stderr)
+ print("", file=sys.stderr)
+ sys.exit(0 if DRY_RUN else 1)
+
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pre-receive b/pre-receive
new file mode 100644
index 0000000..d5ffb9b
--- /dev/null
+++ b/pre-receive
@@ -0,0 +1,45 @@
+#!/usr/bin/python3 -u
+
+# Copy of hooks/post-receive from the gitolite-scripts tarball (Source0), after
+# python.patch. Keep in sync when that hook changes.
+
+import glob
+import importlib.util
+import os
+import sys
+import subprocess
+
+
+def load_source(name, path):
+ spec = importlib.util.spec_from_file_location(name, path)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+data = sys.stdin.readlines()
+
+if (os.path.isdir('hooks/pre-receive.d')):
+ for hook in sorted(os.listdir('hooks/pre-receive.d')):
+ hook = os.path.join('hooks/pre-receive.d', hook)
+ if (hook.endswith(('~','.bak','.rpmsave','.rpmnew')) or
+ not (os.path.isfile(hook) and os.access(hook, os.X_OK))):
+ continue
+ hook_process = subprocess.Popen([hook], stdin=subprocess.PIPE)
+ try:
+ for line in data:
+ hook_process.stdin.write(line.encode('utf-8'))
+ except IOError:
+ pass
+ hook_process.communicate()
+ # Unlike post-receive, a non-zero exit here is what rejects the
+ # push, so it must be propagated instead of ignored.
+ if hook_process.returncode != 0:
+ sys.exit(hook_process.returncode)
+
+for pluginfile in glob.glob('hooks/pre-receive.python.d/*.py'):
+ plugin = load_source(os.path.basename(pluginfile), pluginfile)
+ ret = plugin.run(data)
+ if ret:
+ sys.exit(ret)
================================================================
---- gitweb:
http://git.pld-linux.org/gitweb.cgi/packages/pld-gitolite.git/commitdiff/ff7b65c41a1eae7fb82dafdab53b5a007a9961f5
More information about the pld-cvs-commit
mailing list