Prove where a file was built.
GAWhen a CI job that declares outputs goes green, TillForge writes down which commit, workflow, runner and image produced which bytes, and signs that statement with your workspace’s key. Anyone holding the file and the public key can check it, without trusting the place they downloaded it from.
What gets signed
Declare the files a job builds under artifacts:. The runner hashes them after the job’s container exits and reports the digest of the image the job ran in. Pin the image by digest and the statement carries that digest too.
jobs:
build:
image: node:22@sha256:<digest>
steps:
- npm ci
- npm run build
artifacts:
- dist/*.tgzEach record gets an in-toto Statement v1 whose predicate is SLSA Provenance v1, wrapped in a DSSE envelope of type application/vnd.in-toto+json. It is signed twice by the same workspace key: ECDSA P-256, which today’s verifiers read, and ML-DSA-65, so the record stays checkable once classical signatures no longer are. The private halves are made inside TillSecrets, sealed there, and never leave it.
| Field | What it holds |
|---|---|
subject | Each declared output: its path and sha256. |
externalParameters | The repository URL, ref, workflow and what triggered the run. |
internalParameters | Your workspace, the TillForge record id and digest, and the runner that ran the job. |
resolvedDependencies | The source commit, and the job image with its digest when the runner resolved one. |
runDetails | The builder id below, the job id, and when the job started and finished. |
TillForge CI, version 1
https://tilldev.dev/docs/tillforge/provenance#build-type-ci-v1. A job from .tillforge/ci.yml at the commit in resolvedDependencies, run in a fresh container from the listed image, in a new workspace that holds the checkout and is deleted afterwards. The outputs are the declared paths after the container exits. Symlinks, paths outside the workspace and files over 8 GiB are refused rather than hashed.
A runner your workspace operates
https://tilldev.dev/docs/tillforge/provenance#builder-workspace-runner. The job ran on a TillForge runner registered to your workspace, named in internalParameters.runner. TillForge hands it the job and records what it reports; the host itself is yours.
SLSA build levels
| Level | When a record gets it |
|---|---|
| L0 | Outputs are recorded, but there is no signed provenance yet: waiting for a signature, or signing failed after every retry. |
| L2 | The record is signed by a key that only TillSecrets holds, from facts TillForge recorded rather than ones the job wrote. |
| Release | A release is not a build. Each asset takes the level of a CI build that produced the same bytes, an asset no build produced is L0, and the release is the lowest of its assets. |
Check a file
The CLI needs both signatures to verify under one of your keys, then a statement that names the file’s sha256. With --keys it works offline. Key ids are the sha256 of each public key, so a keys file that has been edited is refused.
# Each pipeline and the level its newest outputs reached.
tilldev forge provenance
# Save a record's signed provenance. Every line is checked against the workspace keys first.
tilldev forge provenance download acme-api <record-id>
# Is this the file that build produced? Exits 1 when it isn't.
tilldev forge provenance verify ./dist/app.tar.gz --bundle acme-api-1a2b3c4d.intoto.jsonl
# The same check with no network and no login.
tilldev forge provenance keys --out tillforge-keys.json
tilldev forge provenance verify ./dist/app.tar.gz --bundle acme-api-1a2b3c4d.intoto.jsonl --keys tillforge-keys.jsonWithout the CLI, the P-256 signature checks with OpenSSL and jq:
# The P-256 key and its id, from: tilldev forge provenance keys --out tillforge-keys.json
jq -r '.keys[0].pem' tillforge-keys.json > tillforge.pub
KEY_ID=$(jq -r '.keys[0].key_id' tillforge-keys.json)
line=$(head -n 1 build.intoto.jsonl)
echo "$line" | jq -r .payload | base64 -d > statement.json
printf 'DSSEv1 28 application/vnd.in-toto+json %s ' "$(wc -c < statement.json | tr -d ' ')" > signed.bin
cat statement.json >> signed.bin
echo "$line" | jq -r --arg k "$KEY_ID" '.signatures[] | select(.keyid == $k) | .sig' | base64 -d > sig.der
openssl dgst -sha256 -verify tillforge.pub -signature sig.der signed.bin
# Then compare the file's digest with the subjects.
jq -r '.subject[] | "\(.digest.sha256) \(.name)"' statement.json
shasum -a 256 app.tar.gzA release’s download holds one envelope per CI build behind its assets. Records and keys are also on the API: GET /api/forge/repos/{repo}/artifact-records/{id}/provenance and GET /api/forge/provenance/keys.
Keys, rotation and signing again
Each workspace has one active key, made the first time it signs. Owners and admins retire it in TillForge → Provenance or with tilldev forge provenance keys rotate; the next build is signed with a new key. Retired keys stay listed, so older records still verify.
Anyone with write access to a repository can sign a CI record again from its stored facts, for example after a rotation or a failed attempt. A workspace may ask 30 times an hour. Rotations and re-signs are in the TillForge audit log.
Next: CI to declare outputs, or the security model. Back to the TillForge overview.