Timestamping every release: a GitHub Actions workflow for tamper-evident builds
Software supply-chain attacks come in two flavours. Pre-build attacks compromise the source tree, the developer's machine, the build environment, or a dependency that gets pulled in — SolarWinds, XZ, the npm event-stream incident. By the time the artefact is produced, it's already malicious; the build is faithfully reproducing a poisoned input. Post-build attacks substitute, modify, or rewind an artefact after it's been built but before it's been installed — a registry compromise, a misconfigured artefact store, a leaked publishing token, the recent tj-actions GitHub Actions incident.
This post is about the second category. Cryptographic timestamping doesn't catch pre-build compromises — if your CI builds a backdoored binary, it'll happily stamp a backdoored binary, and the timestamp will be valid forever. What it does catch is everything that happens after the build runner has produced the file: did this exact byte sequence exist at this moment, and is the file an installer is about to run the same one? For that question, RFC 3161 timestamping with a deploy-time verification gate is a sharp tool. It complements pre-build controls (reproducible builds, dependency scanning, signed source); it does not replace them.
Reproducible builds answer what was built. SBOMs answer what went into the build. Timestamping answers when the artefact existed — and lets you prove a year later that what's running in production is the same byte sequence the build produced.
This post walks through a small GitHub Actions workflow that timestamps every build artefact your repository produces, and then a deploy-time verification gate that refuses to ship anything that isn't in your evidence store. It's about thirty lines of YAML for the stamp step, fifteen for the gate. The resulting timestamps are anchored to publicly trusted certificate authorities (DigiCert, Sectigo, GlobalSign, SwissSign), are independently verifiable with openssl, and land automatically in your Sigill Evidence Store where a downstream consumer can look them up by hash without any account or API key.
What this protects against — and what it doesn't
Before the workflow, the threat model in two lists:
Caught by this pattern:
- Substitution of the artefact between build and install (registry compromise, artefact-store overwrite, in-flight tampering)
- Re-promotion of a wrong or older artefact whose hash isn't current
- Cross-tenant attestation forgery (an attacker stamping a malicious binary under a different identity, caught by the verified-domain check)
- Long-term did this exact byte sequence exist on day X questions during incident response, audit, or dispute
Not caught by this pattern:
- Source compromise before the build runs (SolarWinds-class)
- Malicious dependency pulled in at build time (XZ-class, dependency confusion, typosquatting)
- Compromised CI runner that builds a malicious artefact and then stamps it (the stamp is valid; the contents are not)
- Secrets leakage, credential theft, or compromise of the publishing identity itself
- Anything about the correctness of the artefact — timestamping commits to "this binary existed", not "this binary is good"
If your threat model lives in the second list, this post isn't the answer — you want SLSA build provenance, hermetic builds, signed source, and dependency review. Timestamping is the bottom layer in a stack that assumes the layers above are already in place.
What's standard, what's Sigill
The cryptographic primitive is open: RFC 3161 has been an IETF standard since 2001, every major Certificate Authority operates a free TSA, and the verification path uses openssl directly. You could implement everything in this post against DigiCert's public TSA endpoint with a self-hosted hash database and skip Sigill entirely. The pattern is not proprietary.
What Sigill adds is the operational layer: round-robin failover across multiple TSAs (so a single TSA outage doesn't fail your build), a hosted public hash lookup at a stable URL (so you don't run a database for the verifier), the verified-domain attribution in lookup responses (which lets your deploy gate distinguish your stamps from someone else's), and the Evidence Store as a tenant-scoped record of every stamp ever made. Those are conveniences, not magic — but they're conveniences that turn a pattern requiring real engineering into one that takes thirty lines of YAML.
The rest of this post uses Sigill's API for concreteness. Where a step uses something Sigill-specific (the auto slug, the submitter.domain field, the /api/lookup/{hash} endpoint), it's flagged. Everything else translates directly to any RFC 3161 setup.
The pattern
For each artifact your CI produces — a release tarball, a Docker image manifest, a JAR, a binary, an SBOM file, anything — compute its SHA-512 hash, send the hash to a Time-Stamping Authority via Sigill, and store the resulting .tsr token alongside the artifact in your release bundle.
The TSA never sees your artifact. Only the hash leaves your build runner. The token it returns is a short binary blob (a few KB) that any third party can verify against the artifact bytes — without contacting Sigill, without trusting GitHub, without trusting you.
Here's the shape of the workflow:
The workflow
Create .github/workflows/release.yml (or add these steps to an existing release workflow):
name: Release with timestamp
on:
push:
tags: ['v*']
jobs:
build-and-stamp:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ── Your existing build step ─────────────────────────────────────
# Replace with whatever produces your artifact. The pattern is the
# same whether you're building a tarball, a JAR, a Docker image,
# a wheel, or a static binary — anything that lands as a file.
- name: Build
run: |
make release
# Assume the build produced ./dist/myapp-${{ github.ref_name }}.tar.gz
# ── Stamp the artifact ───────────────────────────────────────────
- name: Timestamp artifact
env:
SIGILL_API_KEY: ${{ secrets.SIGILL_API_KEY }}
run: |
ARTIFACT="dist/myapp-${{ github.ref_name }}.tar.gz"
B64=$(base64 -w0 "$ARTIFACT")
curl -fsS -X POST https://api.sigill.ai/tsa/stamp \
-H "Authorization: Bearer $SIGILL_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"tsaSlug\": \"auto\",
\"fileBase64\": \"$B64\",
\"label\": \"${{ github.repository }}@${{ github.ref_name }} (${{ github.sha }})\"
}" \
> stamp-response.json
# Extract the .tsr token next to the artifact
jq -r .tsrBase64 stamp-response.json | base64 -d > "${ARTIFACT}.tsr"
# Log which TSA actually served the request
echo "Stamped by: $(jq -r .tsaName stamp-response.json) at $(jq -r .genTime stamp-response.json)"
# ── Publish ──────────────────────────────────────────────────────
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/myapp-${{ github.ref_name }}.tar.gz
dist/myapp-${{ github.ref_name }}.tar.gz.tsr
That's the entire integration. Three things to notice:
The label field carries the build provenance. We embed repository@tag (sha) so that when you look at the record later — in the Evidence Store, in a downstream verification, in an incident response — you immediately see which commit produced which artifact. Sigill stores the label on the record; it's not part of the cryptographic proof, but it's part of the audit trail.
tsaSlug: "auto" is what you want for CI. Each request round-robins across the TSAs your tenant has enabled and falls over to the next one if any single TSA has a bad day. CI is exactly the wrong place to discover that DigiCert is having an outage; auto mode makes that someone else's problem. If every TSA fails, you get a structured 502 with which ones were tried and why — easy to surface as a CI failure if you want to.
The .tsr becomes a release asset. Anyone who downloads myapp-v1.2.3.tar.gz can also download myapp-v1.2.3.tar.gz.tsr and verify it themselves, with no Sigill account required.
Verifying — by you, by anyone
There are three verification paths, in increasing order of paranoia. Pick whichever fits your downstream consumer.
1. Drop the file into Sigill's public verifier. No account, no API key, no command line. A consumer downloads myapp-v1.2.3.tar.gz, drags it onto sigill.ai/verify, and sees:
> ✓ Timestamp found > This file was stamped through Sigill.ai > First stamped: 2026-04-26 15:16:09 GMT · TSA: Sectigo · 1 timestamp
The verifier hashes the file in the browser and looks up the hash against the Evidence Store. If your tenant has its domain verified, it surfaces who stamped it, not just that it was stamped. This is the friendliest path for a non-technical recipient — a lawyer, a regulator, a procurement officer.
2. Verify locally with openssl, no Sigill involvement at all. This is the "I don't trust the verifier either" path. The .tsr token shipped alongside your release is independently verifiable against the original artefact and a publicly trusted CA bundle:
curl -LO https://github.com/you/myapp/releases/download/v1.2.3/myapp-v1.2.3.tar.gz
curl -LO https://github.com/you/myapp/releases/download/v1.2.3/myapp-v1.2.3.tar.gz.tsr
curl -LO https://sigill.ai/tsa-ca-bundle.pem
openssl ts -verify \
-in myapp-v1.2.3.tar.gz.tsr \
-data myapp-v1.2.3.tar.gz \
-CAfile tsa-ca-bundle.pem
# Verification: OK
If anyone has modified the tarball — a single byte changed, a malicious dependency injected, a backdoor inserted — verification fails. The hash inside the .tsr token won't match the recomputed hash. The token itself is RSA-signed by a publicly trusted Certificate Authority, so forging the token requires forging a CA's signature, which is the threat model RFC 3161 was built against.
3. Public hash lookup, scriptable, no auth. Useful from any tool that can make an HTTP call — installers, monitors, deploy workflows (more on that in the next section):
HASH=$(openssl dgst -sha512 myapp-v1.2.3.tar.gz | awk '{print $2}')
curl https://api.sigill.ai/api/lookup/$HASH | jq
The response includes the timestamp, the TSA that issued it, and — if your tenant's domain is verified — your organisation's name and domain. A downstream user can confirm in one HTTP call that the binary they're holding is the binary your CI produced, signed off by an independent TSA, attributed to your verified domain. No proprietary tooling, no portal login, no custom verification client. Curl.
The first path is what most users will reach for. The second is what auditors and security-conscious teams will do. The third is what your own deploy workflow should do — every promotion to production gated on a hash that appears in your evidence store. All three give the same cryptographic answer.
Where the records live
Every stamp made via your CI's API key lands in your Evidence Store, the same way every stamp from the web UI does. There's nothing special about CI-produced records — they're tagged with whatever label you pass, attributable to the same tenant, visible in the same listing. Filter by label prefix and you have a chronological view of every release your repository has ever shipped, each one cryptographically anchored to the moment it was built.
For audit purposes this is genuinely valuable. "Show us the evidence trail for v1.2.3" used to mean grepping logs across CI, container registries, and artifact stores, hoping nothing had been rotated out. With timestamped records in the Evidence Store, it's one search.
Gate your deploys on the evidence
The most useful thing you can do with a hash lookup endpoint is block deploys that fail it. Verification doesn't have to be a thing that happens at the end of an incident — it can be the thing that prevents the incident.
Add a verification step to your deploy job, before any artefact reaches production:
deploy:
needs: build-and-stamp
runs-on: ubuntu-latest
steps:
- name: Download release artifact
run: |
# However your deploy normally fetches the artifact —
# from a registry, a release, an S3 bucket, the CI cache.
gh release download "${{ github.ref_name }}" \
--pattern "myapp-*.tar.gz"
- name: Verify against Sigill evidence store
run: |
ARTIFACT=$(ls myapp-*.tar.gz)
HASH=$(openssl dgst -sha512 "$ARTIFACT" | awk '{print $2}')
RESPONSE=$(curl -fsS "https://api.sigill.ai/api/lookup/$HASH")
FOUND=$(echo "$RESPONSE" | jq -r .found)
if [ "$FOUND" != "true" ]; then
echo "::error::Artifact hash not found in evidence store. Refusing to deploy."
echo "Hash: $HASH"
exit 1
fi
# Check the stamp came from our own verified domain — not someone else's
DOMAIN=$(echo "$RESPONSE" | jq -r .latest.submitter.domain)
if [ "$DOMAIN" != "yourcompany.com" ]; then
echo "::error::Artifact stamped by '$DOMAIN', expected 'yourcompany.com'. Refusing to deploy."
exit 1
fi
# Check it isn't suspiciously old (e.g. an artefact from a previous
# release accidentally being redeployed)
STAMPED=$(echo "$RESPONSE" | jq -r .latest.genTime)
echo "✓ Verified: stamped $STAMPED by $DOMAIN"
- name: Deploy
run: ./deploy.sh
This catches three failure modes that are otherwise invisible:
Tampering between build and deploy. A registry compromise or a misconfigured artefact store could let someone substitute a malicious binary after the build. The deploy gate refuses anything whose hash isn't in your evidence store.
Cross-tenant attacks. A stamped binary isn't enough — someone stamped it, and that someone could be the attacker. The submitter.domain check pins the attestation to your verified organisational identity. An attacker would need to compromise both your CA-issued domain proof and your CI's API key to forge an attestation that passes this check.
Wrong artefact, right shape. An old release accidentally re-promoted, or a build from a feature branch that wasn't supposed to ship — these have valid stamps but at the wrong time. Add a freshness check (genTime within the last N hours) and the gate catches them.
The same pattern works for consumers of your software, not just your own pipeline. A regulated buyer running a vendor's release in production can wire this exact check into their deploy workflow — "don't promote anything whose hash doesn't appear in [vendor].com's evidence store" — and have a cryptographic gate against substitution attacks at install time. The vendor doesn't have to do anything; the public lookup endpoint works for everyone.
For high-volume automation, consider caching the lookup response per-hash (the answer is immutable — once a hash is in the store, it stays in the store), and treating Sigill API outages as deploy-fail rather than deploy-pass. "Fail closed" is the only correct posture for a security gate.
A note on Sigstore
If you've followed the supply-chain conversation at all you're familiar with Sigstore, the OpenSSF project that does keyless signing of artifacts via a transparency log (Rekor). Sigstore is excellent and we recommend it for most open-source distribution scenarios — if you publish to npm, PyPI, GitHub Container Registry, or Kubernetes ecosystem destinations, Sigstore has first-class integration with all of them.
The cases where Sigill complements or replaces Sigstore in CI are narrower:
- Long-term archival. Sigstore's threat model is "verify at install time." RFC 3161 timestamps with archival restamping (RFC 3161 §4) are designed for "prove in 2034 that this 2026 build is intact." If you have 10+ year retention requirements (regulated industries, medical devices, public infrastructure), the archival angle matters.
- eIDAS qualified evidence. Sigstore is excellent but it isn't an EU Qualified Trust Service. For artefacts that need to be admissible as qualified electronic evidence under EU law — DORA, the Cyber Resilience Act, regulatory build attestations — a QTSP-backed timestamp carries reversed burden of proof under eIDAS Article 41(2).
- Closed-source and proprietary builds. Sigstore's gravity is in open source and public transparency logs. If you're shipping internal binaries or commercial software you don't want indexed publicly, Sigill's per-tenant Evidence Store is the simpler fit.
- Non-code artefacts. Stamping isn't limited to binaries. SBOMs, signed model weights, training datasets, evaluation reports, regulatory submissions — anything that hashes is something you can timestamp. Sigstore is increasingly adding non-code support but RFC 3161 has covered all of it natively for twenty years.
For most projects, both have a place: Sigstore for the public distribution side, RFC 3161 timestamping for the durability and qualified-evidence side. They're not mutually exclusive — you can run them in the same workflow.
Where to take it next
This workflow stamps one artifact per release. A few directions:
- Stamp every artifact in a matrix build. If your release produces multiple platform binaries, stamp each one — the workflow above is trivially
for each artifact in dist/.
- Stamp the SBOM, not just the binary. A timestamp on
myapp-v1.2.3.cdx.json(or.spdx.json) gives you proof of which dependency tree was claimed at release time. If a transitive dependency is later found to be compromised, you can prove whether your release predated the compromise — useful for vulnerability disclosure timelines.
- Stamp the Git commit, not just the build.
git rev-parse HEADplus the tag, hashed and stamped, gives you an attestation of what source produced the build, separately from the build artefact itself. Combined, you get a chain: source → build → artefact → installation, each link timestamped.
- Add a verification job. A separate workflow that runs daily and re-verifies your last N releases against the bundled
.tsrfiles catches any drift in the released artefacts. It's aforloop and anopenssl ts -verify.
- Stamp downstream of Sigstore. If you already cosign-sign your container images, stamp the resulting Sigstore bundle to get RFC 3161 archival validity on top of Sigstore's at-install-time validity.
What changes when you adopt this
Cryptographic timestamping doesn't eliminate trust. It moves the trust anchor — and that move matters more than it sounds.
| Question | Without timestamping | With timestamping |
|---|---|---|
| What was shipped on day X? | CI logs, artefact-store metadata, your own records — all mutable, all rotatable, all controlled by you | A .tsr token countersigned by a publicly audited CA — durable, externally verifiable, controlled by no one |
| Who attests to the artefact-time binding? | You do, via systems you operate | A Time-Stamping Authority you don't operate, anchored to the public CA ecosystem |
| How long does the evidence stay valid? | As long as your logs survive, your access keys work, and no operator alters them | RFC 3161 §4.3 archival restamping extends validity indefinitely; survives company reorganisations, vendor changes, decades |
| What does an auditor or counterparty have to take on faith? | Your operational practices and your good faith | A widely understood IETF standard and a publicly audited CA — same trust model as TLS |
That's the substantive shift: not eliminating the need for trust, but moving it from your operational posture to a cryptographic primitive backed by infrastructure you don't have to defend, maintain, or convince anyone about. For long-lived evidence that has to outlive the team, the company, or the procurement relationship that produced it, that's the move that matters.
If you want to try it, sign up for an API key, drop the workflow above into your repo, and tag a release. Your first stamp shows up in the Evidence Store within seconds, verifiable from any machine on the internet without further setup.
Got an interesting CI integration story? Reach out — we like talking about this stuff.