fix(gitea): handle disabled git hooks gracefully in secure-gitea.py
Sync GitHub / sync (push) Successful in 7s
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:
+157
-25
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user