Skip to content

Git Notes for CI/CD Annotations

Overview

Git notes let you attach free-form metadata to any commit without modifying the commit SHA. Unlike commit messages, notes are stored separately in refs/notes/* — invisible to normal diffs, merges, and pull request views, but fully queryable by CI/CD pipelines, tooling, and dashboards.

Primary use cases in DevOps: - Annotate commits with CI build IDs, test results, coverage percentages, and artifact URLs - Stamp deployments with environment, version, and rollback targets - Record approval chains, security scan results, or compliance attestations - Tag problematic commits with post-mortem links or investigation notes

How Notes Work

Notes are stored as additional refs (branches) under refs/notes/commits by default:

commit abc1234 ("feat: add rate limiter")
  note refs/notes/commits  →  blob → "ci/build: v2.14.0 | coverage: 91.4% | pipeline: #4821"

The note ref itself is a separate commit object, so it is git push-able, git fetch-able, and can be shared across clones.

Core Commands

# Add a note to the current HEAD
git notes add -m "ci: build #4821 | coverage 91.4% | artifact sha256:abc123"

# Add a note to a specific commit
git notes add <SHA> -m "security-scan: passed | trivy CVE: 0"

# Append to an existing note (default: replaces)
git notes append <SHA> -m "deployment: prod eu-west-1 @ 2026-01-15T08:00:00Z"

# Edit a note interactively
git notes edit <SHA>

# View a note
git notes show       # note on HEAD
git notes show <SHA>

# List all notes refs
git notes list

# Remove a note
git notes remove <SHA>
git notes remove -m "old message" HEAD   # remove by content match

CI/CD Integration

GitHub Actions — Annotate a Commit After Build

# .github/workflows/ci.yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # need full history for git notes refs

      - name: Run tests
        run: make test

      - name: Record CI result as git note
        run: |
          COVERAGE=$(cat coverage.txt)
          PIPELINE_ID="${{ github.run_id }}"
          git config --local user.email "ci@github-actions"
          git config --local user.name "CI Bot"
          git notes append \
            --ref=ci \
            HEAD \
            -m "pipeline=$PIPELINE_ID coverage=$COVERAGE status=pass ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"

          # Push the notes ref back (requires git push on the notes ref)
          git push origin refs/notes/ci

Security note: fetch-depth: 0 is required to fetch the existing notes ref. If your repo uses a shallow clone in CI, use git fetch origin refs/notes/ci:refs/notes/ci instead to pull only the notes ref without full history.

Fetch Notes in a Downstream Pipeline

# Pull the notes ref alongside normal fetch
git fetch origin refs/notes/ci:refs/notes/ci

# View all CI notes for recent commits
git log --format="%H %s" -10 | while read sha msg; do
  note=$(git notes --ref=ci show "$sha" 2>/dev/null)
  echo "$sha  $msg"
  echo "  CI: $note"
done

Multiple Note Namespaces

Use named note refs to separate concerns:

Ref Purpose
refs/notes/ci CI build IDs, coverage, artifact URLs
refs/notes/security Vulnerability scan results, CVE counts
refs/notes/deploy Deployment timestamp, environment, version
refs/notes/approve Human approval chain
# Work with a named note ref
git notes --ref=deploy add -m "deployed to production @ 2026-01-15T08:00:00Z by github-actions"
git notes --ref=deploy show HEAD

# Default is refs/notes/commits; switch default
git config notes.ref "ci"

Shell Helpers

# tag: ci-annotate.sh
# Attach a CI note to a commit with structured data

set -euo pipefail

REF="${1:-HEAD}"
shift || true

# Parse key=value pairs from args
declare -A FIELDS
for arg in "$@"; do
  key="${arg%%=*}"
  val="${arg#*=}"
  FIELDS["$key"]="$val"
done

TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
NOTE="ts=$TS"
for key in "${!FIELDS[@]}"; do
  NOTE="$NOTE ${key}=${FIELDS[$key]}"
done

git notes --ref=ci append "$REF" -m "$NOTE"
echo "Annotated $(git rev-parse --short $REF) with: $NOTE"
# tag: ci-fetch-notes.sh
# Fetch CI notes and print structured annotations for recent commits

NOTES_REF="${1:-ci}"
COUNT="${2:-20}"

git fetch origin "refs/notes/$NOTES_REF:refs/notes/$NOTES_REF" 2>/dev/null

git log --format="📌 %h %s" -"$COUNT" | while IFS= read -r line; do
  echo "$line"
  sha=$(echo "$line" | awk '{print $2}')
  note=$(git notes --ref="$NOTES_REF" show "$sha" 2>/dev/null) && echo "   $note" || true
done

Sharing Notes Across Clones

Notes are pushed and fetched like branches — they are not included in normal git push or git pull:

# Push notes to origin
git push origin refs/notes/ci
git push origin 'refs/notes/*'   # push all notes refs

# Fetch notes into a fresh clone
git fetch origin refs/notes/ci

In CI, configure the fetch refspec to always pull notes:

# In GitHub Actions checkout
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    ref: ${{ github.ref }}
# Notes are included in full-history fetch

For GitLab CI or self-hosted runners, add a pre-step:

git fetch origin +refs/notes/*:refs/notes/* || true

Anti-Patterns and Gotchas

Pitfall Solution
Notes not pushed by default Always git push origin refs/notes/<ref> explicitly
Notes not fetched in shallow clones git fetch origin refs/notes/ci:refs/notes/ci
Note conflicts on rebase Notes are tied to SHA — rebase orphaning loses notes; use git notes copy to migrate
Large notes payload in monorepos Keep notes < 1 KB; offload artifacts to object storage
Default refs/notes/commits clutters output Use named namespaces (--ref=ci) and set notes.displayRef to filter git log output

See Also

  • man git-notes
  • man git-confignotes.displayRef, notes.ref
  • docs/recipes/notes-quick.md — at-a-glance cheat sheet
  • scripts/notes-manage.sh — annotate, fetch, and query notes
  • docs/devops-workflows/git-plumbing-ci-automation.md — synthetic commits and fast CI scripting

Quick Script: notes-manage.sh (excerpt)