Plumber Score
The signed score push
A score anyone can verify
On GitHub Actions, the Plumber CLI signs the score it publishes. A signed score brings:
- Proof of origin. Anyone can check that a published score came from your repository’s own workflow, at a given commit, in a given run. Not from a laptop, not from a fork.
- Nothing to manage. No signing key to create, store or rotate, on your side or on Plumber’s. The job’s GitHub identity is the key.
- A public record. Each signed push is recorded in Sigstore’s transparency log. The CLI prints the log index, and you can look the entry up at any time.
- Verified organization pages. For an official organization, only signed pushes from the locked workflow count toward the organization’s verified grade and attestation.
Pushes that are not signed keep working exactly as before.
When the push is signed
The CLI signs when all three hold:
- the run publishes a score (
score-push: true, or--score-push); - the destination is
score.getplumber.io. A self-hosted service that verifies bundles opts in withPLUMBER_SCORE_SIGN=true; - the job runs on GitHub Actions with
permissions: id-token: write.
Otherwise the push goes out unsigned. GitLab pipelines and local runs publish unsigned. Without score-push, the CLI makes no request at all.
What happens
The report. The CLI builds the report it publishes, with three provenance keys:
cliVersion,configFileSha256(the sha256 of your committed.plumber.yaml) andci(runId,runAttempt,runUrl).Token A. A GitHub OIDC token for the score service. It authenticates the push, as before.
Token B. A GitHub OIDC token for Sigstore. GitHub fills in the repository, owner, ref, commit, workflow, trigger and runner. The job cannot edit it.
A one-time key pair, generated in memory and discarded once the bundle exists.
The trusted root. The Sigstore public keys, fetched through TUF and cached.
The certificate. Fulcio checks token B and issues a certificate, valid about ten minutes, carrying the job identity.
The signature, over the exact bytes of the report, with the one-time key.
The log entry. Rekor records the hash, the signature and the certificate, and returns the log index, a signed timestamp and an inclusion proof.
The bundle. Certificate, log entry and signature, in one Sigstore bundle.
The push. The report is posted with the bundle in the
Plumber-Sigstore-Bundleheader. The CLI prints:✓ Plumber Score published: https://score.getplumber.io/github.com/ORG/REPO✓ report signed (Rekor index 3648963887)The service verifies the bundle offline, reads the identity from the certificate and, for an official organization, checks the workflow, the configuration and the coverage before counting the result.
Time budget and failure
Signing never fails the run. The whole of steps 4 to 9 is capped at 60 seconds: 15 seconds per Fulcio or TUF request, 30 seconds and two retries for the Rekor upload.
Any failure prints one warning and the push goes out unsigned:
⚠️ report not signed (sign the report: Post "https://rekor.sigstore.dev/api/v1/log/entries": giving up after 1 attempt(s): context deadline exceeded); pushed without a Sigstore bundle (the run is not failed)For an official organization, that run is stored as unverified and the organization page keeps the last verified result until the next signed run.
What the signature proves
It proves where the bytes came from: this workflow, at this commit, on this GitHub-hosted runner, in this run attempt, produced exactly this report.
- The identity comes from GitHub’s token, which the job can request but not edit.
- The signature covers the pushed bytes: one changed byte fails it.
- The log entry is public and time-stamped: a bundle without it is refused.
It does not prove that the report is a correct analysis. A job that tampers with the CLI signs its tampered output with a valid identity. That is why an official organization must run the locked workflow: a fixed set of pinned actions, one Plumber step at a released version, hosted runners only.
The log entry is public
The Rekor entry names the repository, the workflow path, the commit and the sha256 of the report. It never contains the report itself.
Check a run yourself
Set PLUMBER_SCORE_PAYLOAD_OUT=<path> before the run: the CLI writes the exact published bytes there. Then compare the hash the log recorded with the hash of that file, using the index the CLI printed:
idx=3648963887curl -sSf "https://rekor.sigstore.dev/api/v1/log/entries?logIndex=$idx" \ | python3 -c 'import json,base64,sys; e=json.load(sys.stdin); u=next(iter(e)); print(json.loads(base64.b64decode(e[u]["body"]))["spec"]["data"]["hash"]["value"])'sha256sum payload.jsonThe two values are equal. rekor-cli get --log-index $idx prints the same entry with the certificate, whose extensions name the repository, the commit and the workflow that signed it.
Reference
| Item | Value |
|---|---|
| Request header | Plumber-Sigstore-Bundle, the Sigstore bundle, base64-encoded, at most 64 KiB |
| Report keys | cliVersion, configFileSha256, ci.runId, ci.runAttempt, ci.runUrl |
| Signed for | score.getplumber.io, or any endpoint when PLUMBER_SCORE_SIGN=true |
| Where | GitHub Actions with permissions: id-token: write; GitLab and local runs publish unsigned |
| Identity provider | GitHub Actions OIDC |
| Certificate authority | https://fulcio.sigstore.dev, 15 s per request, one retry |
| Transparency log | https://rekor.sigstore.dev, 30 s per request, two retries |
| Overall cap | 60 s for the whole signing, then the push goes out unsigned |
| Exact pushed bytes | PLUMBER_SCORE_PAYLOAD_OUT=<path> |
| On failure | One warning, unsigned push, the run never fails |