Skip to content

Git push safety for CI/CD pipelines

Automated pipelines, feature branch workflows, and self-hosted remotes demand strict push controls. A CI script with git push --force can destroy a teammate's work in under a second. This guide covers client-side safety mechanisms, server-side enforcement policies, and plumbing-level push verification that keeps pipelines fast without giving anyone the keys to rewrite history.


The push lifecycle

Every git push goes through these stages:

  1. Client resolves refspecs — local refs match to remote refs
  2. Client negotiates pack — objects missing on the remote are bundled
  3. Server runs pre-receive hook — stdin lists every ref update
  4. Server checks receive.* config — deny rules, fsck, cert validation
  5. Server fires update hooks — one per ref update (can reject individually)
  6. Server applies ref updates — fast-forward checks run here
  7. Server runs post-receive hook — notification only

A DevOps engineer can enforce policy at stages 3, 4, and 5. Client-side safety lives in how the push is invoked (stage 1-2).


Client-side safety

Force-with-lease (always, never bare --force)

--force accepts any new object as the ref target. It clobbers whatever the remote has. --force-with-lease checks that the remote ref still points to the expected old value before updating — it protects against concurrent pushes.

# Dangerous: overwrites whatever is on the remote
git push --force origin feat/x

# Safe: only overwrites if the remote hasn't moved since you last fetched
git push --force-with-lease origin feat/x

# Explicit lease: name the expected old SHA
git push --force-with-lease=feat/x:<expected-sha> origin feat/x

CI pipelines that force-push to feature branches (a common pattern for squash commits or rebased PR branches) must always use --force-with-lease. The one exception is automated release branches where the pipeline controls the only writer.

Force-if-includes (Git 2.30+)

--force-if-includes adds a reachability check: the remote ref's current tip must be reachable from the local push ref. This prevents pushing a ref that was rebased onto a base that itself moved.

# Push only if the remote tip is an ancestor of the new tip
git push --force-with-lease --force-if-includes origin feat/x

Configure it as the default:

git config --global push.useForceIfIncludes true

Atomic pushes

--atomic ensures all refs update together or none do. Without it, a multi-ref push can partially succeed — some refs land, others fail. A partial push leaves the remote in an inconsistent state.

git push --atomic origin main feat/x

CI pipelines that push multiple refs (for example a version tag + a commit) must use --atomic or push them separately with explicit ordering.

Push options

Push options pass metadata to the server-side hooks without affecting the ref update. Useful for CI identity, bypass tokens, or audit context:

git push --push-option=pipeline-id=1234 \
         --push-option=triggered-by=release-bot \
         origin main

Server-side hooks read these from GIT_PUSH_OPTION_{N} environment variables.

Signed pushes

When receive.certNonceSecret is set on the server, the client signs the push certificate. This lets server-side hooks verify the push came from an authenticated pipeline:

git push --signed origin main

A signed push creates a push certificate — a signed JSON payload covering every ref update. The post-receive hook can log the certificate for audit trails.

Avoiding credential leaks in push URLs

Embedding tokens in push URLs leaks them into .git/config, build logs, and git remote -v output:

# Leaks on every git remote -v and in any CI log that prints the URL
git remote add origin https://token:ghp_xxx@github.com/org/repo.git

Use credential helpers or http.extraHeader instead:

# Safer: inject token only at push time via credential helper
git config --global credential.helper "!f() { echo username=token; echo password=$CI_TOKEN; }; f"

# Or via extraHeader
git config --global http.https://github.com/.extraHeader "Authorization: Bearer $CI_TOKEN"

Server-side enforcement

Deny rules

These config keys sit in the bare repository's config (or in the receiving repository) and gate pushes at the receive handler:

Setting Effect
receive.denyNonFastForwards = true Rejects any push that isn't a fast-forward (force-push is blocked)
receive.denyDeletes = true Rejects git push --delete or :refs/heads/x syntax
receive.denyCurrentBranch = updateInstead Rejects pushes to the current checked-out branch of a non-bare repo (prevents a deployed repo from having its HEAD moved out from under the running process)
receive.denyCurrentBranch = warn Logs a warning but allows the push
receive.denyCurrentBranch = refuse Hard rejects pushes to the current branch

Set them in the remote repo:

git config receive.denyNonFastForwards true
git config receive.denyDeletes true

For self-hosted Forgejo/Gitea or plain Git remotes, these are the first line of defense. A CI pipeline pushing to main should hit denyNonFastForwards and fail at the protocol level.

Fsck on push

receive.fsckObjects validates every object in the pushed pack before accepting the ref update. It catches bit rot, truncated objects, and SHA collisions at push time:

git config receive.fsckObjects true

Combine with transfer.fsckObjects which catches corruption during clone and fetch:

git config --global transfer.fsckObjects true

Push certificate validation

When receive.certNonceSecret is set, Git requires a valid push certificate for every push. The server generates a one-time nonce, the client signs it, and the server verifies the signature. This prevents replay attacks and confirms the pusher's identity:

Server setup (bare repo):

# Generate a secure random secret
openssl rand -hex 32 | git config receive.certNonceSecret

# Require signed pushes
git config receive.certNonceSecret

The client must push with --signed and have a configured GPG/SSH signing key. CI pipelines can use machine-level signing keys scoped to the pipeline identity.

Update hook per-ref policy

The update hook runs once per ref being updated. It receives: ref-name old-SHA new-SHA. This is more granular than the deny rules — you can enforce branch-specific policies:

#!/bin/bash
# hooks/update — protect main and release branches from force-push
ref=$1
old=$2
new=$3

case "$ref" in
  refs/heads/main|refs/heads/release/*)
    # Only allow fast-forwards
    if ! git merge-base --is-ancestor "$old" "$new"; then
      echo "Rejected: $ref does not allow force-push" >&2
      exit 1
    fi
    ;;
  refs/heads/feature/*)
    # Allow force-push but verify minimum commit message format
    commit_msg=$(git log --format=%s -1 "$new")
    if ! echo "$commit_msg" | grep -qE '^(feat|fix|chore|docs|refactor)\(?.*\)?:\s'; then
      echo "Rejected: feature branch commits must follow conventional-commits format" >&2
      exit 1
    fi
    ;;
esac
exit 0

Pre-receive hook global policy

The pre-receive hook reads all ref updates from stdin at once (unlike update which fires per-ref). This is where you enforce multi-ref invariants: atomicity, tag signing, and cross-ref consistency:

#!/bin/bash
# hooks/pre-receive — enforce atomic tag+commit pushes
while read old new ref; do
  case "$ref" in
    refs/tags/v*)
      if ! git verify-tag "$new" &>/dev/null; then
        echo "Rejected: tag $ref must be signed" >&2
        exit 1
      fi
      ;;
    refs/heads/main)
      if ! git merge-base --is-ancestor "$old" "$new"; then
        echo "Rejected: main must fast-forward" >&2
        exit 1
      fi
      # Verify every commit in the push range is signed
      for sha in $(git rev-list "$old..$new"); do
        if ! git verify-commit "$sha" &>/dev/null; then
          echo "Rejected: commit $sha in push to main is not signed" >&2
          exit 1
        fi
      done
      ;;
  esac
done
exit 0

Receive-side logging for audit

The post-receive hook logs every successful push for audit trails. Push options and the push certificate make each entry actionable:

#!/bin/bash
# hooks/post-receive — log every ref update
logfile="/var/log/git-push-audit.log"
while read old new ref; do
  timestamp=$(date -Iseconds)
  pusher="${GIT_PUSH_OPTION_0:-unknown}"
  pipeline="${GIT_PUSH_OPTION_1:-unknown}"
  echo "$timestamp | $ref | $old..$new | pusher=$pusher | pipeline=$pipeline" >> "$logfile"
done

CI pipeline push patterns

Squash-and-push for PR branches

The most common CI force-push pattern: a pipeline rebases a feature branch onto a moved base and pushes the result.

# CI pipeline — safe force-push after rebase
git fetch origin main
git rebase origin/main
git push --force-with-lease --force-if-includes origin "$CI_BRANCH"

Tag-and-release push

Release pipelines push both a commit and a tag. A failed tag push after a successful commit push leaves the repo in a half-released state.

# CI pipeline — atomic push for release
git add -A && git commit -m "release: v$VERSION"
git tag -s "v$VERSION" -m "Release v$VERSION"
git push --atomic origin main "v$VERSION"

Pull-request merge push (protected branch)

CI that merges PRs into a protected branch must respect server-side deny rules. The safest pattern is a local merge then push:

# CI pipeline — merge PR into main without force-push
git fetch origin
git checkout origin/main
git merge --no-ff "$MERGE_SOURCE" -m "merge: PR #$PR_ID"
git push origin HEAD:main

If denyNonFastForwards is set, a merge commit is a fast-forward from the remote's perspective: the merge base is an ancestor of the new tip, so the push succeeds.

Emergency hotfix push (bypass)

When a production incident demands bypassing normal push rules, the pipeline or operator must still avoid bare --force:

# Emergency hotfix — explicit lease with known expected SHA
git push --force-with-lease=main:$(git rev-parse origin/main) origin main

If another commit landed on main in the meantime, the lease check fails — forcing the operator to acknowledge the concurrent change.


Configuring a self-hosted remote for CI

Complete server-side hardening for a Forgejo/Gitea or bare Git remote used by CI pipelines:

# /path/to/repo.git/config — CI-grade push safety
git config receive.denyNonFastForwards true
git config receive.denyDeletes true
git config receive.fsckObjects true
git config receive.certNonceSecret "$(openssl rand -hex 32)"
git config transfer.fsckObjects true

# If the repo is bare and used as a shared remote
git config core.bare true

# Max object size check (rejects pushes with objects > 10 MB)
git config receive.maxObjectSize 10485760

For a Forgejo/Gitea instance, set these at the repository level via the admin panel or the app.ini defaults:

[repository]
DISABLE_HTTP_GIT = false
ENABLE_PUSH_CREATE_USER = false
ENABLE_PUSH_CREATE_ORG = false

Push safety checklist for CI pipeline setup

Check Why
No bare --force in any script Blocks accidental clobbering of peer work
--force-with-lease on every force-push Protects concurrent branch work
push.useForceIfIncludes = true set globally Prevents rebase-on-moved-base push
--atomic on multi-ref pushes Prevents partial push state
receive.denyNonFastForwards = true on main Hard blocks force-push to protected branches
receive.fsckObjects = true on remote Catches corruption at push time
transfer.fsckObjects = true on remote Catches corruption at clone/fetch time
Pre-receive hook enforces commit signing on main Supply-chain integrity
Post-receive hook logs every push with push options Audit trail for incident response
No embedded tokens in remote URLs Prevents credential leaks

  • docs/devops-workflows/git-credential-helpers.md — token injection without URL leaks
  • docs/devops-workflows/git-hooks-devops.md — server-side hook deployment
  • docs/devops-workflows/git-commit-signing.md — GPG/SSH signing setup
  • docs/devops-workflows/git-refspecs-and-mirroring.md — multi-remote push mechanics