Docs · Glidepath
Commit signing (Phase 3 item 2, sub-item 1 of SLSA/Sigstore/Tekton Chains)
Developers sign commits with gitsign - keyless, short-lived identity-bound certs via
an interactive OIDC login, logged to Sigstore’s public transparency log (Rekor). The
release stage’s provenance governance gate verifies that signature and checks the
signer’s email against a per-app allowlist - the first of this platform’s governance
gates to do real work instead of being a structurally-loud stub (see
docs/governance-stubs.md).
Why this is public Sigstore, not self-hosted
Two different signing use cases in this platform’s SLSA/Sigstore work end up needing different infrastructure, decided deliberately rather than defaulting to one answer for both:
gitsign(this doc): a human developer, interactively authenticating - the public Sigstore instances (fulcio.sigstore.dev/rekor.sigstore.dev) plus a real OIDC identity provider (GitHub, confirmed as the choice for this platform) is exactly how most real orgs already do this. Zero new infrastructure.- Tekton Chains (a later Phase 3 item): a cluster workload identity, no human in the
loop - the public Fulcio doesn’t trust an arbitrary Kubernetes cluster’s own OIDC
issuer (its trusted identity providers are a fixed set:
google/spiffe/github/filesystem), so that needs a self-hosted Fulcio+Rekor instead. Not this doc’s concern - covered when that item is built.
Developer setup
-
Install
gitsign(and its optional credential cache):brew install sigstore/tap/gitsign # macOS # or download the linux_amd64/linux_arm64 binary from # https://github.com/sigstore/gitsign/releases -
Configure git (per-repo or
--global):git config gpg.x509.program gitsign git config gpg.format x509 git config commit.gpgsign true -
Commit as normal. The first commit (or first every ~10 minutes, without the credential cache) opens a browser to
https://oauth2.sigstore.dev/auth- Sigstore’s own Dex-based OIDC broker, which then redirects to GitHub to authenticate. gitsign gets a short-lived (~10 minute) certificate from Fulcio bound to your GitHub identity, signs the commit with it, and logs the signature to Rekor. The cert itself is discarded after signing - nothing long-lived to leak or rotate.Which email lands in the cert depends on your GitHub privacy setting. If “Keep my email addresses private” is enabled on your GitHub account (common default), the identity asserted is your noreply address,
<numeric-id>+<username>@users.noreply.github.com- not your real email, even ifgit config user.emailis set to something else (that field is never read by gitsign/Fulcio; it’s purely local git metadata). Confirmed live: a real signed commit here came back with7268337+jfillman@users.noreply.github.com, notcrossfader@gmail.com, despite the local git config. Either allowlist the noreply address (see below) or disable that GitHub privacy setting - both are supported. -
Optional but recommended - avoid re-authenticating every commit: run
gitsign-credential-cache(the same release asset asgitsign) as a background process; it holds the cert/key in memory over a local socket for its ~10-minute lifetime so a burst of commits doesn’t mean a burst of browser prompts. -
Verify locally, if you want to check your own work:
gitsign verify --certificate-identity=<your-email-or-noreply-address> \ --certificate-oidc-issuer=https://github.com/login/oauth HEAD
How provenance verifies it
provenance runs as a Check on the gitops repo’s release PR, but the commit that
needs a verified human signature is the app-repo commit being promoted - the gitops
commit itself is generated by open-release-pr.yaml’s own automation identity and is
never hand-signed. open-release-pr.yaml embeds X-App-Repo/X-App-Commit trailers in
its own commit message specifically so charts/glidepath-catalog/templates/tasks/verify-commit-signature.yaml (run
via charts/glidepath-catalog/templates/pipelines/provenance-check.yaml) can find its way back to the right commit: it
reads those trailers, does a narrow single-commit git fetch of just that one app-repo
commit (no second PaC Repository CR/webhook needed on the app repo for this), and runs:
gitsign verify \
--certificate-identity-regexp "^(email1|email2|...)$" \
--certificate-oidc-issuer "https://github.com/login/oauth" \
FETCH_HEAD
The --certificate-oidc-issuer value is https://github.com/login/oauth, not
https://oauth2.sigstore.dev/auth - confirmed by inspecting a real signed commit’s
cert, after the first version of this doc/Task got it wrong by trusting gitsign version’s default-config output instead. oauth2.sigstore.dev is the Dex-based broker
gitsign talks to during login, but it federates to an upstream IdP (GitHub here), and
it’s the upstream connector’s own issuer URL that ends up in the cert’s issuer claim -
verifying against the broker URL fails against every real cert.
Merge strategy matters: a GitHub-clicked merge is never signed
This isn’t optional trivia - get it wrong and every release verification fails, for a
reason that looks like a platform bug but isn’t one (confirmed live, 2026-08-11,
diagnosing a real provenance failure: Error: unsupported signature type: not a PEM block).
gitsign signs one exact commit object - the signature is computed over that commit’s
own serialized content (tree, parent(s), author, committer, message). Change any of
those fields and you get a different object with a different SHA; the old signature
doesn’t carry over. All three of GitHub’s PR merge-button strategies mutate the commit:
- Create a merge commit - a brand-new two-parent commit, authored/committed by GitHub. Never signed by you.
- Squash and merge - a brand-new single commit combining the diff. Your original signed commits don’t even land on the target branch.
- Rebase and merge - GitHub replays each commit itself, producing new commit objects
(new SHAs) distinct from the ones
gitsignactually signed locally.
So on GitHub specifically, there is no PR merge button that preserves your
gitsign-signed commit object onto main. Since provenance verifies whatever
commit GIT_REVISION actually resolves to (a push/tag event’s own commit, per “How
provenance verifies it” above) - not “some ancestor of it was once signed” - clicking
Merge on GitHub always produces a commit that fails this check, structurally, regardless
of whether the PR’s own individual commits were properly signed.
The two things that actually work:
- A true fast-forward. The target branch’s ref moves to point at your
already-signed commit with zero new commit created -
git push origin maindirectly, orgit merge --ff-only <branch> && git push, never GitHub’s web merge button. - Tag the commit you actually signed, not whatever the merge button produced.
release-on-tag’s trigger doesn’t care whether the tagged commit ismain’s current HEAD - it only needs (a) an image already built for that exact SHA (fromci’s push-triggered build, which does require the commit to have reachedmainvia a real push at some point) and (b) a valid signature on it. So: push your signed commit tomainvia fast-forward (satisfyingci’s build trigger), thengit tag vX.Y.Z <that-same-sha>and push the tag directly - never re-derive the tag from whatevermain’s HEAD happens to be after a PR got merged through GitHub’s UI.
The practical tension worth naming: “open a PR, get it reviewed, click Merge” - the default workflow almost everyone uses - is structurally incompatible with this gate as built, because the click itself is what destroys the signature. Reviewing via PR is still fine; the merge step just can’t be a GitHub button. A team that wants both real PR review and a working signature gate needs either a fast-forward-only merge policy enforced outside GitHub’s UI (a bot that rebases-and-pushes rather than merges), or to decouple “what gets tagged/released” from “what the merge button produced,” as above.
Per-app allowed signers
charts/glidepath-app/templates/governance/policy-config.yaml - a ConfigMap
(<app-name>-policy-config, in the Application’s own namespace) with an
allowed-commit-signers key: one email per line, # comments and blank lines ignored.
The verify Task builds the regex from this list at runtime, escaping both . and +
(both are regex metacharacters that show up in real addresses - + matters in practice,
not just in theory, since GitHub’s noreply format is <id>+<username>@users.noreply. github.com) - onboarding or updating the allowed list is just editing plain emails,
never hand-written regex.
Remember GitHub’s privacy setting affects what to list here (see step 3 above) - a Application’s allowlist may need the developer’s noreply address, their real email, or both, depending on that developer’s own GitHub account settings.
Verification
- Sign a real test commit as an allowed email, push it through the real chain, confirm
provenancepasses as a real (not stub) GitHub Check. - Sign a commit as an email not on the Application’s allowed list (or leave a commit
unsigned entirely), confirm
provenancegenuinely fails, and that branch protection (already configured to require this check,docs/release.md) actually blocks the merge - not just that the Task reports failure in isolation. kubectl auth can-i get configmaps/<app-name>-policy-config --as=system:serviceaccount:<app-namespace>:pipeline-runner -n <app-namespace>confirms the RBAC addition is exactly as scoped - nothing broader.