Enforce file immutability through layered git hooks. Mark a file with the literal text @immutable inside a native comment for its language, and every subsequent attempt to modify, rename, or delete that file is blocked.
| # | Layer | Where it runs | Bypassable with |
|---|---|---|---|
| 1 | pre-commit hook |
Local (git commit) |
git commit --no-verify |
| 2 | pre-push hook |
Local (git push) |
git push --no-verify |
| 3 | GitHub Actions | Server-side, on PR + push to main |
Repo admin disables CI |
| 4 | Branch protection | GitHub setting on main |
Repo admin disables it |
Each layer catches what the layer above it failed to. A developer who skips --no-verify twice still hits CI; CI cannot be bypassed without admin rights once branch protection requires it.
Put the literal text @immutable inside a comment in the file. The comment syntax is whatever is native to that file type, so the file remains valid:
| File type | Comment | Example marker line |
|---|---|---|
| SQL | -- |
-- @immutable |
| C#, Java, JS, TS, Go, C, C++, Rust | // |
// @immutable |
| Python, Shell, PowerShell, YAML, TOML | # |
# @immutable |
| HTML, XML, Markdown | <!-- --> |
<!-- @immutable --> |
| CSS | /* */ |
/* @immutable */ |
The hooks do not parse comment syntax; they just look for the literal string @immutable in the file's stored blob. Putting it in a comment is what keeps your file syntactically valid.
First-commit rule: a file may be added with the marker. Once committed, future Modify / Delete / Rename operations are blocked.
core.hooksPath is a per-clone setting; new clones don't pick it up automatically. Each contributor runs the bootstrap once:
# bash / WSL / macOS / Linux
./scripts/enable-hooks.sh# Windows / PowerShell
./scripts/enable-hooks.ps1Verify:
git config core.hooksPath
# .githooksTry to edit samples/locked.sql and commit:
echo "-- evil change" >> samples/locked.sql
git add samples/locked.sql
git commit -m "tamper"
# Commit blocked: immutable file(s) modified.
# M samples/locked.sqlTwo commits are required, and reviewers should treat the first one as the audit gate:
-
Remove the
@immutablemarker. Commit with a message that explains why the file is no longer immutable. CI will allow this because the post-change blob no longer matches future immutability checks — but the pre-change blob did, so the hook will fire. This commit is the one that must be authorised.In practice this means: either land it via a reviewed PR while CI is your enforcement (recommended), or use
--no-verifylocally with explicit justification recorded in the commit body, then push and let CI + reviewers be the gate. -
Make the actual change in a second commit.
Re-add the marker afterwards if the file should become immutable again.
- Push the repo and open a PR to
main. Confirm the Immutable file check workflow runs. - Settings → Branches → Add classic branch protection rule (or Rulesets) for
main:- Require a pull request before merging.
- Require status checks to pass before merging → select Reject changes to @immutable files.
- Do not allow bypassing the above settings.
- (Optional) Require signed commits.
- (Optional) Add a
CODEOWNERSentry covering immutable files so a human reviewer must approve any PR that removes a marker.
Set IMMUTABLE_MARKER in the environment (hooks honour it; you'd also need to mirror it into the workflow env: block):
IMMUTABLE_MARKER='@locked' git commit -m "...".githooks/ # in-repo hooks (referenced via core.hooksPath)
_lib.sh
pre-commit
pre-push
.github/workflows/
immutable-check.yml # server-side enforcement
scripts/
enable-hooks.sh
enable-hooks.ps1
samples/ # demo files with native-comment markers
locked.sql
Locked.cs
locked.ps1
locked.html
editable.sql # control: not marked, edits allowed
git commit --no-verify/git push --no-verifybypass layers 1 and 2. That is exactly why layer 3 (CI + branch protection) exists.- Hooks only run on local clones that have run
enable-hooks.sh. Treat layers 1–2 as ergonomics; layer 3 is the real enforcement. - A force-push that rewrites history to remove the marker before the change can defeat CI on push-to-main. Branch protection should forbid force pushes to
main.