Hangar

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

  1. 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
  2. Configure git (per-repo or --global):

    git config gpg.x509.program gitsign
    git config gpg.format x509
    git config commit.gpgsign true
  3. 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 if git config user.email is 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 with 7268337+jfillman@users.noreply.github.com, not crossfader@gmail.com, despite the local git config. Either allowlist the noreply address (see below) or disable that GitHub privacy setting - both are supported.

  4. Optional but recommended - avoid re-authenticating every commit: run gitsign-credential-cache (the same release asset as gitsign) 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.

  5. 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 gitsign actually 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:

  1. 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 main directly, or git merge --ff-only <branch> && git push, never GitHub’s web merge button.
  2. Tag the commit you actually signed, not whatever the merge button produced. release-on-tag’s trigger doesn’t care whether the tagged commit is main’s current HEAD - it only needs (a) an image already built for that exact SHA (from ci’s push-triggered build, which does require the commit to have reached main via a real push at some point) and (b) a valid signature on it. So: push your signed commit to main via fast-forward (satisfying ci’s build trigger), then git tag vX.Y.Z <that-same-sha> and push the tag directly - never re-derive the tag from whatever main’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 provenance passes 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 provenance genuinely 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.