fix(gitea): handle disabled git hooks gracefully in secure-gitea.py
Sync GitHub / sync (push) Successful in 7s

Previously, when Gitea is installed with DISABLE_GIT_HOOKS=true (the
default), every git-hook API call returned 403 and the script:
- marked every repository as FAILED, and
- never counted the hook step at all.

This change detects the disabled state up front with a single probe
against the first editable repo:
- If hooks are disabled, it prints a clear notice, skips the pre-receive
  step for every repo (branch/tag protection still runs normally), and
  exits non-zero (2) so automation notices the run is partial.
- A per-repo 403 on the hook step is isolated (HookNotWritable) so it no
  longer clobbers the whole repo into FAILED -- branch/tag still apply,
  and the hook reports 'SKIPPED (git hooks not writable)'.
- Adds a Hooks skipped counter and distinguishes 'disabled' vs
  'not writable' in the summary.
- Fixes an UnboundLocalError on the 'no editable repos' path by
  initializing hooks_enabled before the probe.

Verified end-to-end against a mock Gitea API for both scenarios (hooks
disabled -> skip + exit 2; hooks enabled -> exact/stale/missing handled
correctly, exit 0).
This commit is contained in:
2026-08-26 05:18:53 -04:00
parent 9415ded220
commit a56d97d13d
+157 -25
View File
@@ -94,6 +94,20 @@ TAG_DESIRED = {
} }
# When Gitea is installed with DISABLE_GIT_HOOKS=true (the default), the
# git-hook API returns 403 for every repository regardless of who the token
# belongs to -- even a site admin. The pre-receive guard below cannot be
# applied until an admin enables git hooks. We detect this up front (once)
# and skip all hook management so the run doesn't spam a 403 per repo.
GIT_HOOKS_ENABLED = None # True/False once probed; None = unknown
# Sentinels the hook helpers raise (instead of requests.HTTPError) so that a
# git-hook failure is isolated from branch/tag failures and never marks a
# whole repository as FAILED.
class HookNotWritable(Exception):
"""Raised when the git-hook API rejects us (usually hooks disabled)."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Desired Git hook configuration # Desired Git hook configuration
# #
@@ -436,6 +450,23 @@ def apply_tag_protection(owner, repo, existing):
# Git hooks # Git hooks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def hook_request_allowed(response):
"""Return True if the response means hooks are usable, False if the
server rejected the request (403 -> DISABLE_GIT_HOOKS), and raise if
the failure is something else we should surface."""
if response.ok:
return True
if response.status_code == 403:
# Gitea returns this when the authenticated user cannot manage git
# hooks, which happens whenever DISABLE_GIT_HOOKS is true (even for
# admins). Treat it as "hooks disabled / not writable".
return False
return response.raise_for_status()
def get_git_hook(owner, repo, hook_name): def get_git_hook(owner, repo, hook_name):
""" """
Return the existing {hook_name} hook, or None if it is not set. Return the existing {hook_name} hook, or None if it is not set.
@@ -443,13 +474,21 @@ def get_git_hook(owner, repo, hook_name):
Gitea returns is_active=false with empty content when the hook is not Gitea returns is_active=false with empty content when the hook is not
configured. Treat that as "not set" so it is reported and applied configured. Treat that as "not set" so it is reported and applied
like the branch/tag protections below. like the branch/tag protections below.
Raises HookNotWritable when the git-hook API is not usable (e.g. git
hooks disabled), so callers can report it without failing the repo.
""" """
hook = api( url = (
"GET",
f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/" f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/"
f"{quote(hook_name)}", f"{quote(hook_name)}"
) )
response = session.request("GET", f"{GITEA_URL}/api/v1{url}")
if not hook_request_allowed(response):
raise HookNotWritable(url)
hook = response.json()
if not hook.get("is_active"): if not hook.get("is_active"):
return None return None
@@ -469,13 +508,20 @@ def describe_git_hook(hook):
def apply_git_hook(owner, repo, hook_name): def apply_git_hook(owner, repo, hook_name):
"""Set the hook content to the desired value.""" """Set the hook content to the desired value."""
api( url = (
"PATCH",
f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/" f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/"
f"{quote(hook_name)}", f"{quote(hook_name)}"
)
response = session.request(
"PATCH",
f"{GITEA_URL}/api/v1{url}",
json={"content": GIT_HOOKS[hook_name]}, json={"content": GIT_HOOKS[hook_name]},
) )
if not hook_request_allowed(response):
raise HookNotWritable(url)
response.raise_for_status()
return "UPDATED" return "UPDATED"
@@ -539,6 +585,55 @@ def main():
print(f"Found {len(repositories)} repositories.") print(f"Found {len(repositories)} repositories.")
print() print()
# -----------------------------------------------------------------------
# Probe whether the git-hook API is usable
# -----------------------------------------------------------------------
# Gitea returns 403 (even for admins) when DISABLE_GIT_HOOKS is true --
# the default. Detect that once, up front, instead of failing every
# repo's hook step. Use the first non-archived repo as the probe target.
# Initialize hooks_enabled before the probe so every code path has it.
hooks_enabled = True
probe_repo = next(
(r for r in repositories if not r.get("archived", False)),
None,
)
if probe_repo is None:
print("No editable repositories found; nothing to do.")
return
probe_owner = probe_repo["owner"]["login"]
probe_name = probe_repo["name"]
try:
get_git_hook(probe_owner, probe_name, "pre-receive")
print("Git hooks: enabled (git-hook API reachable)")
except HookNotWritable:
hooks_enabled = False
print(
"Git hooks: DISABLED/not writable -- pre-receive hook will be "
"skipped. Enable git hooks in Gitea (DISABLE_GIT_HOOKS=false "
"+ admin) to use the pre-receive guard."
)
except requests.HTTPError:
# Some other API failure on the probe. Don't assume hooks are
# disabled; just note we couldn't verify and continue per-repo.
print(
"Git hooks: could not verify (API error) -- will attempt per repo"
)
hooks_enabled = True
print()
if not hooks_enabled:
print(
"*** Git hooks are disabled; skipping the pre-receive hook step "
"for all repositories. Branch/tag protection is unaffected. ***"
)
print()
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Counters # Counters
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
@@ -551,6 +646,8 @@ def main():
hook_changed = 0 hook_changed = 0
hook_unchanged = 0 hook_unchanged = 0
hook_skipped = 0
hooks_enabled = True
skipped = 0 skipped = 0
failed = 0 failed = 0
@@ -685,31 +782,55 @@ def main():
# Pre-receive Git hook # Pre-receive Git hook
# --------------------------------------------------------------- # ---------------------------------------------------------------
hook = get_git_hook(owner, name, "pre-receive") if hooks_enabled:
try:
hook = get_git_hook(owner, name, "pre-receive")
if hook is None: if hook is None:
print(" Hook: NOT SET") print(" Hook: NOT SET")
print(" Would SET pre-receive hook") print(" Would SET pre-receive hook")
if args.apply: if args.apply:
result = apply_git_hook(owner, name, "pre-receive") result = apply_git_hook(
print(f" {result}") owner,
name,
"pre-receive",
)
print(f" {result}")
hook_changed += 1 hook_changed += 1
elif hook.get("content") == GIT_HOOKS["pre-receive"]: elif hook.get("content") == GIT_HOOKS["pre-receive"]:
print(" Hook: SET (matches desired content)") print(
hook_unchanged += 1 " Hook: SET (matches desired content)"
)
hook_unchanged += 1
else:
print(" Hook: SET (content differs)")
print(" Would UPDATE pre-receive hook")
if args.apply:
result = apply_git_hook(
owner,
name,
"pre-receive",
)
print(f" {result}")
hook_changed += 1
except HookNotWritable:
print(
" Hook: SKIPPED (git hooks not writable)"
)
hook_skipped += 1
else: else:
print(" Hook: SET (content differs)") print(
print(" Would UPDATE pre-receive hook") " Hook: SKIPPED (git hooks disabled)"
)
if args.apply: hook_skipped += 1
result = apply_git_hook(owner, name, "pre-receive")
print(f" {result}")
hook_changed += 1
except requests.HTTPError: except requests.HTTPError:
print(" FAILED") print(" FAILED")
@@ -732,6 +853,12 @@ def main():
print() print()
print(f"Hooks changed: {hook_changed}") print(f"Hooks changed: {hook_changed}")
print(f"Hooks unchanged: {hook_unchanged}") print(f"Hooks unchanged: {hook_unchanged}")
if not hooks_enabled:
print(f"Hooks skipped: {hook_skipped} (git hooks disabled)")
elif hook_skipped:
print(f"Hooks skipped: {hook_skipped} (not writable)")
else:
print(f"Hooks skipped: {hook_skipped}")
print() print()
print(f"Skipped: {skipped}") print(f"Skipped: {skipped}")
print(f"Failed: {failed}") print(f"Failed: {failed}")
@@ -740,6 +867,11 @@ def main():
print() print()
print("Dry run complete. Nothing was changed.") print("Dry run complete. Nothing was changed.")
# If the hook step had to be skipped (e.g. git hooks disabled), this run
# is only partially complete. Exit non-zero so automation notices.
if not hooks_enabled or hook_skipped:
sys.exit(2)
if __name__ == "__main__": if __name__ == "__main__":
main() main()