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: 0is required to fetch the existing notes ref. If your repo uses a shallow clone in CI, usegit fetch origin refs/notes/ci:refs/notes/ciinstead 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:
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-notesman git-config—notes.displayRef,notes.refdocs/recipes/notes-quick.md— at-a-glance cheat sheetscripts/notes-manage.sh— annotate, fetch, and query notesdocs/devops-workflows/git-plumbing-ci-automation.md— synthetic commits and fast CI scripting