Extends secure-gitea.py (the "harden each repo" script) so it also manages the pre-receive git hook on every repo, alongside the existing branch/tag protection.
What it does
Embeds the canonical pre-receive guard content (the same "no workflow changes from untrusted branches" script in pre-receive-guard.sh) as GIT_HOOKS["pre-receive"].
For each repo, reads the current hook via GET /repos/{owner}/{repo}/hooks/git/pre-receive.
If the hook is not set (Gitea returns is_active: false) → reports "NOT SET / Would SET".
If the content differs → reports "SET (content differs) / Would UPDATE".
If it already matches exactly → reports "SET (matches desired content)" and leaves it alone.
Applies via PATCH .../hooks/git/pre-receive with {"content": ...} only under --apply, mirroring the branch/tag behavior (dry-run by default).
Adds Hooks changed / Hooks unchanged to the summary, skips archived repos, and inherits the existing failure handling.
Why exact-match
Verified against Gitea 1.27.2 source:
modules/git/hook.go — GetHook reads hooks/pre-receive.d/pre-receive and returns that content verbatim (via is_active + content); Update() stores it verbatim (only strips \r).
The generated hooks/pre-receive wrapper runs every executable file in pre-receive.d/, so setting this content is all that's required for the guard to take effect.
EditGitHookOption is just {"content": "..."}. So "set properly" == byte-exact match.
Validation
py_compile clean.
Embedded GIT_HOOKS["pre-receive"] is byte-identical to pre-receive-guard.sh (3236 bytes).
Verified end-to-end against a mock Gitea API: correctly detected exact match (unchanged), stale content (UPDATE), missing (SET), and skipped archived repos. The mock asserted the PATCH body equals the canonical guard exactly.
Notes
Runs off the same GITEA_DOMAIN / GITEA_TOKEN / GITEA_ADMIN env vars as the rest of the script.
The API requires the authenticated user to be repo admin and have git-hook privileges (DISABLE_GIT_HOOKS=false + admin or AllowGitHook) — i.e., the boss-tier account the script already uses.
## Summary
Extends `secure-gitea.py` (the "harden each repo" script) so it also manages the **pre-receive git hook** on every repo, alongside the existing branch/tag protection.
## What it does
- Embeds the canonical pre-receive guard content (the same "no workflow changes from untrusted branches" script in `pre-receive-guard.sh`) as `GIT_HOOKS["pre-receive"]`.
- For each repo, reads the current hook via `GET /repos/{owner}/{repo}/hooks/git/pre-receive`.
- If the hook is **not set** (Gitea returns `is_active: false`) → reports "NOT SET / Would SET".
- If the content **differs** → reports "SET (content differs) / Would UPDATE".
- If it **already matches exactly** → reports "SET (matches desired content)" and leaves it alone.
- Applies via `PATCH .../hooks/git/pre-receive` with `{"content": ...}` only under `--apply`, mirroring the branch/tag behavior (dry-run by default).
- Adds **Hooks changed / Hooks unchanged** to the summary, skips archived repos, and inherits the existing failure handling.
## Why exact-match
Verified against Gitea **1.27.2** source:
- `modules/git/hook.go` — `GetHook` reads `hooks/pre-receive.d/pre-receive` and returns that content verbatim (via `is_active` + `content`); `Update()` stores it verbatim (only strips `\r`).
- The generated `hooks/pre-receive` wrapper runs every executable file in `pre-receive.d/`, so setting this content is all that's required for the guard to take effect.
- `EditGitHookOption` is just `{"content": "..."}`. So "set properly" == byte-exact match.
## Validation
- `py_compile` clean.
- Embedded `GIT_HOOKS["pre-receive"]` is **byte-identical** to `pre-receive-guard.sh` (3236 bytes).
- Verified end-to-end against a mock Gitea API: correctly detected *exact match* (unchanged), *stale content* (UPDATE), *missing* (SET), and skipped archived repos. The mock asserted the PATCH body equals the canonical guard exactly.
## Notes
- Runs off the same `GITEA_DOMAIN` / `GITEA_TOKEN` / `GITEA_ADMIN` env vars as the rest of the script.
- The API requires the authenticated user to be repo admin **and** have git-hook privileges (`DISABLE_GIT_HOOKS=false` + admin or `AllowGitHook`) — i.e., the boss-tier account the script already uses.
Adds pre-receive hook management to the existing hardening script:
- Defines GIT_HOOKS with the canonical guard content (rejects workflow
changes arriving via untrusted branches) from pre-receive-guard.sh.
- Reads the current hook via the git-hook API; treats is_active=false
as not set.
- Sets/updates the hook via PATCH only when the content differs, using
exact-match comparison (Gitea stores hook content verbatim).
- Mirrors the existing branch/tag protection reporting (dry-run vs
--apply) and adds hooks to the summary counters.
- Skips archived repos and surfaces API failures like the rest of the
script.
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).
Follow-up fix pushed (a56d97d): handles the DISABLE_GIT_HOOKS=true (default) case gracefully.
Verified against Gitea 1.27.2 source that the git-hook API returns 403 for every repo — even for admins — when git hooks are disabled (CanEditGitHook returns false). The previous revision would have marked every repo FAILED and never counted the hook step.
Now the script:
Probes the git-hook API once, up front (first editable repo).
If disabled → prints a clear notice, skips the pre-receive step for all repos (branch/tag protection still runs), reports Hooks skipped, and exits 2 so automation notices the partial run.
Isolates per-repo hook 403s (HookNotWritable) so a single hook failure no longer clobbers the whole repo into FAILED.
Fixes an UnboundLocalError on the "no editable repos" path.
Validated end-to-end against a mock Gitea API for both scenarios (disabled → skip + exit 2; enabled → exact/stale/missing all handled correctly, exit 0). 19/19 assertions passed.
Follow-up fix pushed (a56d97d): **handles the `DISABLE_GIT_HOOKS=true` (default) case gracefully.**
Verified against Gitea 1.27.2 source that the git-hook API returns **403 for every repo** — even for admins — when git hooks are disabled (`CanEditGitHook` returns false). The previous revision would have marked every repo `FAILED` and never counted the hook step.
Now the script:
- Probes the git-hook API **once, up front** (first editable repo).
- If disabled → prints a clear notice, **skips** the pre-receive step for all repos (branch/tag protection still runs), reports `Hooks skipped`, and **exits 2** so automation notices the partial run.
- Isolates per-repo hook 403s (`HookNotWritable`) so a single hook failure no longer clobbers the whole repo into `FAILED`.
- Fixes an `UnboundLocalError` on the "no editable repos" path.
Validated end-to-end against a mock Gitea API for both scenarios (disabled → skip + exit 2; enabled → exact/stale/missing all handled correctly, exit 0). **19/19 assertions passed.**
andrew
approved these changes 2026-08-26 10:01:52 -04:00
andrew
merged commit 8b9f4819d6 into main2026-08-26 10:02:05 -04:00
andrew
deleted branch feat/pre-receive-hook2026-08-26 10:02:05 -04:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Extends
secure-gitea.py(the "harden each repo" script) so it also manages the pre-receive git hook on every repo, alongside the existing branch/tag protection.What it does
pre-receive-guard.sh) asGIT_HOOKS["pre-receive"].GET /repos/{owner}/{repo}/hooks/git/pre-receive.is_active: false) → reports "NOT SET / Would SET".PATCH .../hooks/git/pre-receivewith{"content": ...}only under--apply, mirroring the branch/tag behavior (dry-run by default).Why exact-match
Verified against Gitea 1.27.2 source:
modules/git/hook.go—GetHookreadshooks/pre-receive.d/pre-receiveand returns that content verbatim (viais_active+content);Update()stores it verbatim (only strips\r).hooks/pre-receivewrapper runs every executable file inpre-receive.d/, so setting this content is all that's required for the guard to take effect.EditGitHookOptionis just{"content": "..."}. So "set properly" == byte-exact match.Validation
py_compileclean.GIT_HOOKS["pre-receive"]is byte-identical topre-receive-guard.sh(3236 bytes).Notes
GITEA_DOMAIN/GITEA_TOKEN/GITEA_ADMINenv vars as the rest of the script.DISABLE_GIT_HOOKS=false+ admin orAllowGitHook) — i.e., the boss-tier account the script already uses.Follow-up fix pushed (
a56d97d): handles theDISABLE_GIT_HOOKS=true(default) case gracefully.Verified against Gitea 1.27.2 source that the git-hook API returns 403 for every repo — even for admins — when git hooks are disabled (
CanEditGitHookreturns false). The previous revision would have marked every repoFAILEDand never counted the hook step.Now the script:
Hooks skipped, and exits 2 so automation notices the partial run.HookNotWritable) so a single hook failure no longer clobbers the whole repo intoFAILED.UnboundLocalErroron the "no editable repos" path.Validated end-to-end against a mock Gitea API for both scenarios (disabled → skip + exit 2; enabled → exact/stale/missing all handled correctly, exit 0). 19/19 assertions passed.