Skip to content

feat: support SOURCE_DATE_EPOCH for reproducible OCI builds#753

Open
tsnaik wants to merge 4 commits intoproject-stacker:mainfrom
tsnaik:add-reproducible-builds-support
Open

feat: support SOURCE_DATE_EPOCH for reproducible OCI builds#753
tsnaik wants to merge 4 commits intoproject-stacker:mainfrom
tsnaik:add-reproducible-builds-support

Conversation

@tsnaik
Copy link
Contributor

@tsnaik tsnaik commented Feb 18, 2026

What type of PR is this?

feature

Which issue does this PR fix:
N/A

What does this PR do / Why do we need it:

Adds support for the SOURCE_DATE_EPOCH environment variable to enable bit-for-bit reproducible OCI image builds. Upgrades umoci from v0.5.0 to v0.6.0, which provides timestamp clamping in tar layers via RepackOptions.SourceDateEpoch.

When SOURCE_DATE_EPOCH is set:

  • All tar layer timestamps are clamped to the epoch value (similar to tar --clamp-mtime)
  • config.created is set to the epoch timestamp
  • history[].created entries use the epoch timestamp
  • The image author is stabilized to "stacker" instead of user@hostname
  • umoci.NewImage receives the epoch to clamp base image metadata

SOURCE_DATE_EPOCH is also added to the default build environment passthrough list so the value is available inside run: sections.

If an issue # is not available please add repro steps and logs showing the issue:

Without SOURCE_DATE_EPOCH, building the same stacker YAML twice produces images with different digests because timestamps and author fields vary between runs. With this change:

export SOURCE_DATE_EPOCH=1700000000
stacker build   # build 1
stacker build   # build 2 → identical digests

Testing done on this change:

Integration tests added in test/reproducible.bats covering 9 scenarios. Manual verification confirmed identical manifest, config, and layer digests across clean rebuilds with the same epoch.

Manual test
❯ cat stacker.yaml
foo:
    from:
        type: docker
        url: docker://aci-zot02.cisco.com:5000/c3/minbase:3.0.24
    run: |
        echo "hello world"
        touch hello.world
        echo $SOURCE_DATE_EPOCH

❯ SOURCE_DATE_EPOCH=1700000000  ./stacker build
Creating new OCI Layout at "/data/hdd/tanaik/workspace/stacker/oci"
preparing image foo...
imported file hashes (after substitutions):
loading docker://aci-zot02.cisco.com:5000/c3/minbase:3.0.24
Copying blob e81c1a2fe083 done   |
Copying blob 5d99703592fa done   |
Copying config 9813e95ce8 done   |
Writing manifest to image destination
+ echo hello world
hello world
+ touch hello.world
+ echo 1700000000
1700000000
filesystem foo built successfully
❯ jq "." oci/index.json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:29e320b9cc3ac16e61b1ba3da5db6bf93206f7e822b58f94200cb3550a9df5b0",
      "size": 1273,
      "annotations": {
        "org.opencontainers.image.ref.name": "foo"
      }
    }
  ]
}
❯ ./stacker clean
❯ SOURCE_DATE_EPOCH=1700000000  ./stacker build
Creating new OCI Layout at "/data/hdd/tanaik/workspace/stacker/oci"
preparing image foo...
imported file hashes (after substitutions):
loading docker://aci-zot02.cisco.com:5000/c3/minbase:3.0.24
Copying blob e81c1a2fe083 done   |
Copying blob 5d99703592fa done   |
Copying config 9813e95ce8 done   |
Writing manifest to image destination
+ echo hello world
hello world
+ touch hello.world
+ echo 1700000000
1700000000
filesystem foo built successfully
❯ jq "." oci/index.json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:29e320b9cc3ac16e61b1ba3da5db6bf93206f7e822b58f94200cb3550a9df5b0",
      "size": 1273,
      "annotations": {
        "org.opencontainers.image.ref.name": "foo"
      }
    }
  ]
}

Automation added to e2e:

Yes — test/reproducible.bats with 9 test cases:

  1. SOURCE_DATE_EPOCH sets config.created timestamp correctly
  2. Author is stabilized to "stacker" when epoch is set
  3. Reproducible single-layer builds (identical digests across clean rebuilds)
  4. History timestamps use epoch value
  5. Without SOURCE_DATE_EPOCH, author remains user@hostname (backward compat)
  6. Invalid SOURCE_DATE_EPOCH produces an error
  7. Reproducible multi-layer builds
  8. Different epoch values produce different timestamps
  9. SOURCE_DATE_EPOCH is available inside run: sections
reproducible.bats output
$ make test TEST=reproducible PRIVILEGE_LEVEL=unpriv 
go build  -tags "exclude_graphdriver_btrfs exclude_graphdriver_devicemapper containers_image_openpgp osusergo netgo " -ldflags "-X stackerbuild.io/stacker/pkg/lib.StackerVersion=v1.1.6-2-gc5abc73 -X stackerbuild.io/stacker/pkg/lib.LXCVersion=5.0.3 " -o stacker-dynamic ./cmd/stacker
echo STACKER_DOCKER_BASE=docker://ghcr.io/project-stacker/
STACKER_DOCKER_BASE=docker://ghcr.io/project-stacker/
echo STACKER_BUILD_BASE_IMAGE=docker://ghcr.io/project-stacker/alpine:3.19
STACKER_BUILD_BASE_IMAGE=docker://ghcr.io/project-stacker/alpine:3.19
./stacker-dynamic --debug --oci-dir=/data/hdd/tanaik/workspace/stacker/.build/oci --roots-dir=/data/hdd/tanaik/workspace/stacker/.build/roots --stacker-dir=/data/hdd/tanaik/workspace/stacker/.build/stacker --storage-type=overlay build \
	-f build.yaml \
	--substitute BUILD_D=/data/hdd/tanaik/workspace/stacker/.build \
	--substitute STACKER_BUILD_BASE_IMAGE=docker://ghcr.io/project-stacker/alpine:3.19 \
	--substitute LXC_CLONE_URL=https://github.com/lxc/lxc \
	--substitute LXC_BRANCH=stable-5.0 \
	--substitute VERSION_FULL=v1.1.6-2-gc5abc73 \
	--substitute WITH_COV=no
stacker version v1.1.6-2-gc5abc73
usernsexec-ing [u 0 1003 1 1 265538 365535 g 0 1002 1 1 265538 365535 -- /data/hdd/tanaik/workspace/stacker/stacker-dynamic --internal-userns --debug --oci-dir=/data/hdd/tanaik/workspace/stacker/.build/oci --roots-dir=/data/hdd/tanaik/workspace/stacker/.build/roots --stacker-dir=/data/hdd/tanaik/workspace/stacker/.build/stacker --storage-type=overlay build -f build.yaml --substitute BUILD_D=/data/hdd/tanaik/workspace/stacker/.build --substitute STACKER_BUILD_BASE_IMAGE=docker://ghcr.io/project-stacker/alpine:3.19 --substitute LXC_CLONE_URL=https://github.com/lxc/lxc --substitute LXC_BRANCH=stable-5.0 --substitute VERSION_FULL=v1.1.6-2-gc5abc73 --substitute WITH_COV=no]
stacker version v1.1.6-2-gc5abc73
initializing stacker recipe: build.yaml
substitution: "BUILD_D=/data/hdd/tanaik/workspace/stacker/.build" was used 1 times
substitution: "STACKER_BUILD_BASE_IMAGE=docker://ghcr.io/project-stacker/alpine:3.19" was used 1 times
substitution: "LXC_CLONE_URL=https://github.com/lxc/lxc" was used 1 times
substitution: "LXC_BRANCH=stable-5.0" was used 1 times
substitution: "VERSION_FULL=v1.1.6-2-gc5abc73" was used 1 times
substitution: "WITH_COV=no" was used 1 times
substitution: "STACKER_ROOTFS_DIR=/data/hdd/tanaik/workspace/stacker/.build/roots" was NOT used
substitution: "STACKER_STACKER_DIR=/data/hdd/tanaik/workspace/stacker/.build/stacker" was NOT used
substitution: "STACKER_OCI_DIR=/data/hdd/tanaik/workspace/stacker/.build/oci" was NOT used
substitution: "STACKER_WORK_DIR=" was NOT used
stacker build order:
0 build /data/hdd/tanaik/workspace/stacker/build.yaml: requires: []
building: 0 /data/hdd/tanaik/workspace/stacker/build.yaml
substitution: "BUILD_D=/data/hdd/tanaik/workspace/stacker/.build" was used 1 times
substitution: "STACKER_BUILD_BASE_IMAGE=docker://ghcr.io/project-stacker/alpine:3.19" was used 1 times
substitution: "LXC_CLONE_URL=https://github.com/lxc/lxc" was used 1 times
substitution: "LXC_BRANCH=stable-5.0" was used 1 times
substitution: "VERSION_FULL=v1.1.6-2-gc5abc73" was used 1 times
substitution: "WITH_COV=no" was used 1 times
substitution: "STACKER_ROOTFS_DIR=/data/hdd/tanaik/workspace/stacker/.build/roots" was NOT used
substitution: "STACKER_STACKER_DIR=/data/hdd/tanaik/workspace/stacker/.build/stacker" was NOT used
substitution: "STACKER_OCI_DIR=/data/hdd/tanaik/workspace/stacker/.build/oci" was NOT used
substitution: "STACKER_WORK_DIR=" was NOT used
Dependency Order [build-env build]
preparing image build-env...
Remote file: hash:  length: 
Couldn't obtain file info of https://github.com/json-c/json-c/archive/refs/tags/json-c-0.16-20220414.tar.gz, using cached copy
Remote file: hash:  length: 
Couldn't obtain file info of https://gitlab.com/cryptsetup/cryptsetup/-/archive/v2.6.0/cryptsetup-v2.6.0.tar.gz, using cached copy
Remote file: hash:  length: 
Couldn't obtain file info of https://github.com/lvmteam/lvm2/archive/refs/tags/v2_03_18.tar.gz, using cached copy
imported file hashes (after substitutions):
overlay-dirs, possibly modified after import: []
loading docker://ghcr.io/project-stacker/alpine:3.19
preparing image build...
imported file hashes (after substitutions):
overlay-dirs, possibly modified after import: []
rebuilding cached layer due to use of binds in stacker file
lxc rootfs overlay arg overlayfs:/data/hdd/tanaik/workspace/stacker/.build/roots/build-env/overlay:/data/hdd/tanaik/workspace/stacker/.build/roots/sha256_17a39c0ba978cc27001e9c56a480f98106e1ab74bd56eb302f9fd4cf758ea43f/overlay:/data/hdd/tanaik/workspace/stacker/.build/roots/build/overlay
stacker version v1.1.6-2-gc5abc73
stacker subcommand: [/data/hdd/tanaik/workspace/stacker/stacker-dynamic --oci-dir /data/hdd/tanaik/workspace/stacker/.build/oci --roots-dir /data/hdd/tanaik/workspace/stacker/.build/roots --stacker-dir /data/hdd/tanaik/workspace/stacker/.build/stacker --storage-type overlay --debug internal-go check-aa-profile lxc-container-default-cgns]
bind mounting /data/hdd/tanaik/workspace/stacker/.build/stacker/imports/build into container at /stacker/imports
bind mounting "/data/hdd/tanaik/workspace/stacker" into container at "/stacker-tree"
bind mounting "/data/hdd/tanaik/workspace/stacker/.build" into container at "/build"
+ export 'HOME=/root'
+ git -C /lxc rev-parse HEAD
+ export 'LXC_VERSION=cb8e38aca27a23964941f0f011a8919aab8bebab'
+ export 'VERSION_FULL=v1.1.6-2-gc5abc73'
+ export 'GOTOOLCHAIN=auto'
+ cd /stacker-tree
+ make 'BUILD_D=/build' show-info
BUILD_D=/build
AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/build/gopath/gocache'
GOCACHEPROG=''
GODEBUG=''
GOENV='/root/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build617585138=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/stacker-tree/go.mod'
GOMODCACHE='/build/gopath/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/build/gopath'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/build/gopath/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.6.linux-amd64'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/root/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/build/gopath/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.6.linux-amd64/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.24.6'
GOWORK=''
PKG_CONFIG='pkg-config'
+ make 'BUILD_D=/build' -C cmd/stacker/lxc-wrapper clean
make: Entering directory '/stacker-tree/cmd/stacker/lxc-wrapper'
rm lxc-wrapper
make: Leaving directory '/stacker-tree/cmd/stacker/lxc-wrapper'
+ '[' xno '=' xyes ]
+ make -C /stacker-tree stacker-static
make: Entering directory '/stacker-tree'
make -C cmd/stacker/lxc-wrapper LDFLAGS=-static LDLIBS="-llxc -lutil -lpthread -ldl -lssl -ldl -pthread -lseccomp -lcrypto -ldl -pthread -lcap  -lpthread -ldl" lxc-wrapper
make[1]: Entering directory '/stacker-tree/cmd/stacker/lxc-wrapper'
cc   -static  lxc-wrapper.c  -llxc -lutil -lpthread -ldl -lssl -ldl -pthread -lseccomp -lcrypto -ldl -pthread -lcap  -lpthread -ldl -o lxc-wrapper
make[1]: Leaving directory '/stacker-tree/cmd/stacker/lxc-wrapper'
go build  -tags "exclude_graphdriver_btrfs exclude_graphdriver_devicemapper containers_image_openpgp osusergo netgo static_build" -ldflags "-X stackerbuild.io/stacker/pkg/lib.StackerVersion=v1.1.6-2-gc5abc73 -X stackerbuild.io/stacker/pkg/lib.LXCVersion=cb8e38aca27a23964941f0f011a8919aab8bebab -extldflags '-static'" -o stacker ./cmd/stacker
make: Leaving directory '/stacker-tree'
build only layer, skipping OCI diff generation
# check only SC2031 which finds undefined variables in bats tests but is only an INFO
shellcheck -i SC2031 test/reproducible.bats
# check all error level issues
shellcheck -S error  test/reproducible.bats
sudo -E PATH="/data/hdd/tanaik/workspace/stacker/hack/tools/bin:/home/tanaik/.pyenv/shims:/home/tanaik/.pyenv/bin:/home/tanaik/.vscode-server/data/User/globalStorage/github.copilot-chat/debugCommand:/home/tanaik/.vscode-server/data/User/globalStorage/github.copilot-chat/copilotCli:/data/hdd/tanaik/vscode-server/cli/servers/Stable-c3a26841a84f20dfe0850d0a5a9bd01da4f003ea/server/bin/remote-cli:/home/tanaik/.pyenv/bin:/home/tanaik/.nvm/versions/node/v22.20.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/home/tanaik/.fzf/bin:/usr/local/go/bin:/home/tanaik/bin:/home/tanaik/.local/bin:/usr/local/go/bin:/home/tanaik/bin:/usr/local/go/bin:/home/tanaik/bin:/home/tanaik/.local/bin" \
	STACKER_BUILD_ALPINE_IMAGE=docker://ghcr.io/project-stacker/alpine:3.19 \
	STACKER_BUILD_BUSYBOX_IMAGE=docker://ghcr.io/project-stacker/busybox:latest \
	STACKER_BUILD_CENTOS_IMAGE=docker://ghcr.io/project-stacker/centos:latest \
	STACKER_BUILD_UBUNTU_IMAGE=docker://ghcr.io/project-stacker/ubuntu:latest \
	./test/main.py \
	--privilege-level=unpriv \
	test/reproducible.bats
1..9
ok 1 SOURCE_DATE_EPOCH sets config created timestamp in 1648ms
ok 2 SOURCE_DATE_EPOCH sets author to stacker in 1850ms
ok 3 SOURCE_DATE_EPOCH produces reproducible builds in 2727ms
ok 4 SOURCE_DATE_EPOCH history timestamps use epoch in 1923ms
ok 5 without SOURCE_DATE_EPOCH author is user@hostname in 1907ms
ok 6 invalid SOURCE_DATE_EPOCH causes error in 314ms
ok 7 SOURCE_DATE_EPOCH with multi-layer produces reproducible builds in 3390ms
ok 8 SOURCE_DATE_EPOCH different values produce different timestamps in 2880ms
ok 9 SOURCE_DATE_EPOCH is available inside run section in 2262ms
running tests in modes: unpriv

Will this break upgrades or downgrades?

No. Behavior is unchanged when SOURCE_DATE_EPOCH is not set.

Does this PR introduce any user-facing change?:

Yes — users can now set SOURCE_DATE_EPOCH to produce reproducible OCI images.

Added SOURCE_DATE_EPOCH support for reproducible OCI image builds. When the environment variable is set, all image timestamps are clamped to the specified epoch and the author field is stabilized, enabling bit-for-bit reproducible builds.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Signed-off-by: Tanmay Naik <tnaik96@gmail.com>
Upgrade umoci from v0.5.0 to v0.6.0, which adds SOURCE_DATE_EPOCH
support for clamping tar entry timestamps (similar to tar
--clamp-mtime).

When the SOURCE_DATE_EPOCH environment variable is set:
- All tar layer timestamps are clamped to the epoch value via
  umoci's RepackOptions.SourceDateEpoch
- OCI image config.created is set to the epoch timestamp
- OCI history[].created entries use the epoch timestamp
- The image author is stabilized to "stacker" instead of user@hostname
- umoci.NewImage receives the epoch to clamp base image metadata

This follows the reproducible-builds.org SOURCE_DATE_EPOCH
specification,
allowing bit-for-bit reproducible OCI image builds when the same epoch
value and identical inputs are provided across builds.

Also adds SOURCE_DATE_EPOCH to the default build environment passthrough
list so the value is available inside build containers.

Includes integration tests (test/reproducible.bats) covering:
- Config timestamp and author field verification
- Reproducibility across clean rebuilds (single and multi-layer)
- Backward compatibility without SOURCE_DATE_EPOCH
- Invalid input error handling
- Different epoch values producing different timestamps

Signed-off-by: Tanmay Naik <tnaik96@gmail.com>
@tsnaik tsnaik force-pushed the add-reproducible-builds-support branch from c5abc73 to e9e6be6 Compare February 18, 2026 21:43
@tsnaik
Copy link
Contributor Author

tsnaik commented Feb 18, 2026

something's up with the existing elasticsearch test case in convert.sh — will look into it locally.

@tsnaik tsnaik force-pushed the add-reproducible-builds-support branch from 7230bcc to 38d1295 Compare February 19, 2026 18:22
The build cache was not aware of SOURCE_DATE_EPOCH. When a layer was
cached from a build with one epoch value, subsequent builds with a
different epoch would hit the cache and silently reuse the old
timestamps and author metadata.

Add SourceDateEpoch to CacheEntry and compare it during Lookup().
A change in the epoch now triggers a cache miss and full rebuild.

Signed-off-by: Tanmay Naik <tnaik96@gmail.com>
Upstream commit[1] removed
the Dockerfile from the main branch, causing this test to fail.

```
dockerfiles/elasticsearch: No such file or directory
```

Clone an older tag that still contains the Dockerfile.

[1]:
elastic/dockerfiles@a25b223

Signed-off-by: Tanmay Naik <tnaik96@gmail.com>
@tsnaik tsnaik force-pushed the add-reproducible-builds-support branch from 38d1295 to 29ce1d6 Compare February 19, 2026 19:04
@codecov
Copy link

codecov bot commented Feb 19, 2026

Codecov Report

❌ Patch coverage is 77.19298% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.93%. Comparing base (ebb4855) to head (29ce1d6).

Files with missing lines Patch % Lines
pkg/overlay/pack.go 64.28% 4 Missing and 1 partial ⚠️
pkg/stacker/cache.go 80.76% 4 Missing and 1 partial ⚠️
cmd/stacker/main.go 75.00% 2 Missing ⚠️
pkg/stacker/build.go 83.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #753      +/-   ##
==========================================
+ Coverage   52.84%   52.93%   +0.08%     
==========================================
  Files          59       59              
  Lines        6492     6535      +43     
==========================================
+ Hits         3431     3459      +28     
- Misses       2418     2431      +13     
- Partials      643      645       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mikemccracken
Copy link
Contributor

all but the arm64 unpriv tests passed. That one printed the 26th test output and then looks like maybe it timed out?

ok 25 bind as struct in 1025ms
ok 26 fail on missing bind source in 435ms

then nothing.

the unpriv amd64 tests have this test sequence:

ok 26 fail on missing bind source in 292ms
# starting zot at localhost:8080
no response from host:localhost port:8080
Got response from host:localhost on port:8080
# zot is up
ok 27 all container contents must be accounted for in 63ms # skip test_all_container_contents_must_be_accounted_for is broken. Set BROKEN_TESTS=true to run.
ok 28 bom tool should work inside run in 2921893ms

test 28 took almost an hour.

Maybe the skipped test 27 didn't make it to the output and the long test 28 caused a timeout?

Do we really need a test that takes 48 minutes on its own? maybe we should mark it as broken

@tsnaik
Copy link
Contributor Author

tsnaik commented Feb 19, 2026

passed on re-trigger. bom tool should work inside run took ~23 minutes this time

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments