Git archive for deterministic artifact packaging and source stamping¶
Building release artifacts directly from a Git repository often introduces subtle build bugs and bloated artifacts. Copying working directories copies untracked files, local configuration overrides, and multi-gigabyte .git history trees into deployment bundles and Docker images. Running sed or build scripts to stamp commit hashes and version strings modifies files in place, dirtying the working directory and breaking build caching.
git archive generates clean, standalone tar and zip archives directly from any Git tree, commit, or tag. Combined with .gitattributes directives (export-ignore and export-subst), git archive strips development-only files and stamps commit metadata into source files without modifying working trees.
Core command syntax and formats¶
git archive reads the Git object store directly. It ignores untracked files, uncommitted edits, and the .git directory itself.
Basic archive creation¶
# Create an uncompressed tar archive of the current HEAD
git archive --format=tar -o release.tar HEAD
# Create a gzip-compressed tar archive of a release tag
git archive --format=tar.gz -o release-v1.4.0.tar.gz v1.4.0
# Create a zip archive of a release branch
git archive --format=zip -o release-v1.4.0.zip release/v1.4
When --output (or -o) has a standard extension (.tar.gz, .tgz, .zip), Git infers the format automatically:
Adding a directory prefix¶
Archives extracted without a root directory can overwrite files in the destination. The --prefix flag encapsulates all archived files inside a root directory:
The trailing slash on --prefix is mandatory. Without the trailing slash, Git prefixes each top-level filename instead of creating a parent directory (for example, my-service-1.4.0src/ instead of my-service-1.4.0/src/).
Archiving subtrees or specific paths¶
To package only a subdirectory or single package from a monorepo:
# Package only the backend service directory from the main branch
git archive --prefix=backend/ -o backend-v1.0.0.tar.gz HEAD:services/backend
# Package specific files or folders from the root
git archive -o client-bundle.tar.gz HEAD src/client/ public/ package.json
Excluding development files with export-ignore¶
Production distribution packages should not contain CI workflow definitions, unit tests, linters, local development containers, or documentation sources.
The export-ignore attribute in .gitattributes instructs git archive to skip designated files and directories.
Configuring .gitattributes¶
Create or edit .gitattributes in the repository root:
# Exclude CI/CD configurations and provider workflows
.github/ export-ignore
.forgejo/ export-ignore
.gitlab-ci.yml export-ignore
azure-pipelines.yml export-ignore
Jenkinsfile export-ignore
# Exclude test suites and test fixtures
tests/ export-ignore
*_test.go export-ignore
*.spec.ts export-ignore
*.test.js export-ignore
pytest.ini export-ignore
vitest.config.ts export-ignore
# Exclude container and development tooling
Dockerfile* export-ignore
docker-compose*.yml export-ignore
.devcontainer/ export-ignore
.editorconfig export-ignore
.prettierrc export-ignore
.eslintrc* export-ignore
# Exclude repository metadata and build scripts
.gitattributes export-ignore
.gitignore export-ignore
scripts/dev/ export-ignore
docs/ export-ignore
Testing export-ignore behavior¶
To verify which files are excluded before publishing:
# Create a test tar and list its contents
git archive HEAD | tar -tf - | grep -E '(\.github|tests|Dockerfile)'
If export-ignore rules are configured correctly, the command returns zero matches.
Stamping build metadata with export-subst¶
Applications often need to display their version, commit hash, build date, or branch name at runtime. Manually editing version files or running regex substitutions during CI builds modifies working tree files and creates dirty commits.
The export-subst attribute tells git archive to replace format placeholders inside designated files with commit metadata during archive creation.
Supported format placeholders¶
Git supports the standard git log --format placeholders inside files marked with export-subst:
| Placeholder | Replaced value | Example output |
|---|---|---|
$Format:%H$ |
Full commit hash | 6ff9f43b614980a5f371fc6edae2ff24dc5bcace |
$Format:%h$ |
Abbreviated commit hash | 6ff9f43 |
$Format:%D$ |
Ref names (tags, branches) | tag: v1.4.0, master |
$Format:%ci$ |
Committer date (ISO 8601) | 2026-09-02 10:15:30 -0400 |
$Format:%cI$ |
Committer date (strict ISO 8601) | 2026-09-02T10:15:30-04:00 |
$Format:%an$ |
Author name | DevOps Engineer |
$Format:%ae$ |
Author email | devops@sneakysquid.xyz |
$Format:%s$ |
Commit subject line | feat(api): add health check endpoint |
Step-by-step metadata injection setup¶
1. Mark target files in .gitattributes¶
2. Create placeholder files in source code¶
JSON template (src/version.json):
{
"commit": "$Format:%H$",
"short_commit": "$Format:%h$",
"refs": "$Format:%D$",
"date": "$Format:%cI$",
"author": "$Format:%an$ <$Format:%ae$>"
}
Python template (src/version.py):
COMMIT_SHA = "$Format:%H$"
SHORT_SHA = "$Format:%h$"
BUILD_DATE = "$Format:%cI$"
REF_NAMES = "$Format:%D$"
def get_version_info():
return {
"commit": COMMIT_SHA,
"short_commit": SHORT_SHA,
"date": BUILD_DATE,
"refs": REF_NAMES,
}
Go template (src/build_info.go):
package main
const (
GitCommit = "$Format:%H$"
GitShortHash = "$Format:%h$"
GitBuildDate = "$Format:%cI$"
GitRefs = "$Format:%D$"
)
3. Archive and verify placeholder expansion¶
# Archive the repository and extract the generated version.json to stdout
git archive HEAD src/version.json | tar -xOf - src/version.json
Output:
{
"commit": "c5099ef3a09919fef61cfb36e3e6e8736f33d7b8",
"short_commit": "c5099ef",
"refs": "HEAD -> master, tag: v1.2.0",
"date": "2026-09-02T09:45:00-04:00",
"author": "DevOps Team <devops@local>"
}
In the normal working directory, src/version.json retains the $Format:...$ placeholders. When exported through git archive, the placeholders become static values.
Deterministic builds and byte-for-byte reproducibility¶
A build is reproducible when compiling or archiving the same commit on different machines produces identical binary files and identical SHA-256 checksums.
Standard tar archives record file modification times (mtime), user IDs, and group IDs from the host system. If two CI runners archive the same commit at different times, standard tar archives produce different checksums.
Enforcing deterministic mtime¶
Git 2.38 and newer support the --mtime option for git archive. This forces every file in the archive to have an identical modification timestamp.
# Set mtime to the exact commit timestamp of the target ref
COMMIT_DATE=$(git log -1 --format=%cI v1.4.0)
git archive --format=tar \
--prefix=release-v1.4.0/ \
--mtime="$COMMIT_DATE" \
v1.4.0 | gzip -n > release-v1.4.0.tar.gz
The -n (or --no-name) flag in gzip is critical. By default, gzip writes the current system timestamp and original filename into the gzip stream header. Passing -n prevents gzip from writing timestamps into the header, resulting in identical hashes across machines.
Verifying reproducible checksums¶
# Run 1
git archive --mtime="2026-01-01T00:00:00Z" --prefix=app/ HEAD | gzip -n > /tmp/run1.tar.gz
sha256sum /tmp/run1.tar.gz
# Run 2 (on a different machine or timestamp)
git archive --mtime="2026-01-01T00:00:00Z" --prefix=app/ HEAD | gzip -n > /tmp/run2.tar.gz
sha256sum /tmp/run2.tar.gz
# Compare hashes
cmp /tmp/run1.tar.gz /tmp/run2.tar.gz && echo "Hashes are byte-for-byte identical"
DevOps and CI/CD pipeline integration patterns¶
Pattern 1: Fast Docker build context streaming¶
Copying a whole repository into docker build sends the entire .git directory to the Docker daemon. This slows builds down on large repositories.
Streaming git archive straight to docker build sends only tracked files, applies .gitattributes filters, and ignores .git:
# Pipe git archive directly into docker build over stdin
git archive HEAD | docker build -t registry.local/app:v1.4.0 -
# Build from a specific subfolder with a Dockerfile inside it
git archive HEAD:services/api | docker build -t registry.local/api:v1.4.0 -
If your Dockerfile is excluded via export-ignore, pass the Dockerfile explicitly using the -f flag:
Pattern 2: Packaging repositories with submodules¶
By default, git archive does not recursively traverse submodules. To include submodules inside a single release archive, use a shell pipeline:
#!/usr/bin/env bash
set -euo pipefail
TARGET_REF=${1:-HEAD}
OUTPUT_FILE=${2:-release-bundle.tar}
PREFIX=${3:-release/}
# Create main repository archive
git archive --prefix="$PREFIX" --format=tar "$TARGET_REF" > "$OUTPUT_FILE"
# Iterate over each initialized submodule and append its archive
git submodule foreach --recursive '
SUB_REL_PATH="${sm_path}"
git archive --prefix="${PREFIX}${SUB_REL_PATH}/" --format=tar HEAD > "/tmp/submodule-${name}.tar"
tar --concatenate --file="'"$(pwd)/$OUTPUT_FILE"'" "/tmp/submodule-${name}.tar"
rm -f "/tmp/submodule-${name}.tar"
'
# Compress final tar bundle
gzip -n -9 "$OUTPUT_FILE"
echo "Created ${OUTPUT_FILE}.gz with all submodules included."
Pattern 3: Remote archive extraction over SSH¶
You can export a repository tree directly to a remote production or staging host without running git clone or installing Git on the remote server:
# Stream archived tag to remote server and extract directly into /opt/app
git archive --format=tar v1.4.0 | ssh deploy@app-server.local "tar -C /opt/app -xf -"
Pattern 4: CI release asset generation in Forgejo Actions or GitHub Actions¶
name: Release Asset Packaging
on:
push:
tags:
- 'v*'
jobs:
package:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate Deterministic Tarball and Zip
run: |
TAG_NAME="${{ github.ref_name }}"
COMMIT_DATE=$(git log -1 --format=%cI "$TAG_NAME")
# Generate tar.gz
git archive --format=tar --prefix="${TAG_NAME}/" --mtime="$COMMIT_DATE" "$TAG_NAME" | gzip -n -9 > "${TAG_NAME}.tar.gz"
# Generate zip
git archive --format=zip --prefix="${TAG_NAME}/" --mtime="$COMMIT_DATE" -o "${TAG_NAME}.zip" "$TAG_NAME"
# Generate checksums
sha256sum "${TAG_NAME}.tar.gz" "${TAG_NAME}.zip" > checksums.sha256
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: release-assets
path: |
*.tar.gz
*.zip
checksums.sha256
Troubleshooting and common pitfalls¶
Pitfall 1: Uncommitted files missing from archive¶
git archive works with commits, trees, and refs in the object database. It cannot archive uncommitted working directory edits.
To archive current working state including uncommitted changes:
# Stash or commit to a temporary synthetic tree object using plumbing
TEMP_TREE=$(git write-tree)
git archive --prefix=work-in-progress/ -o /tmp/wip.tar.gz "$TEMP_TREE"
Pitfall 2: export-subst placeholders remain unexpanded¶
If $Format:%H$ appears literally in exported files:
- Verify
.gitattributeshasexport-substmapped to the exact filename or pattern. - Ensure
.gitattributeswas committed to the repository prior to archiving (or is present in the tree being archived). - Confirm you used
git archive. Standard tools liketar,cp, orcatdo not parse Git attributes.
Pitfall 3: Checksum mismatches across CI environments¶
If archive checksums differ across platforms:
- Ensure
--mtimeis set to an ISO 8601 timestamp (e.g.$(git log -1 --format=%cI <ref>)). - Verify compression uses
gzip -n. Without-n, gzip embeds operating system flags and file modification timestamps into the archive header. - Check Git versions across runners.
git archive --mtimewas introduced in Git 2.38.
Verification commands¶
# List all files in tar archive without extracting
tar -tvf release.tar.gz
# List all files in zip archive without extracting
unzip -l release.zip
# Inspect a single file inside an unextracted tar archive
tar -xOf release.tar.gz release/src/version.json
# Check SHA-256 integrity
sha256sum -c checksums.sha256