Enable keyless signing for CI skill pushes - #6402
Merged
Merged
Conversation
samuv
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
jhrozek,
rdimitrov and
tgrunnagle
as code owners
August 20, 2026 14:20
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6402 +/- ##
=======================================
Coverage 77.69% 77.70%
=======================================
Files 752 756 +4
Lines 72717 72788 +71
=======================================
+ Hits 56500 56561 +61
- Misses 16212 16222 +10
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Base automatically changed from
skills-keyless/03-cli-token-acquisition
to
main
August 21, 2026 08:29
Release skill pushes have been publishing unsigned artifacts behind a `--no-sign` stopgap since the keyless signing work started. The signer, the server-side token plumbing, and the CLI's credential ladder are all in place now, so the stopgap can go: a bare `thv skill push` picks up the job's ambient OIDC token and signs against Fulcio and Rekor. That needs `id-token: write` on both the reusable workflow's job and on releaser's calling job, since GitHub caps a reusable workflow at what its caller grants. Nothing so far proves the resulting signature is one the wider Sigstore ecosystem accepts, only that ToolHive's own verifier reads it back. Add a workflow that signs a throwaway skill against Sigstore's staging Fulcio and Rekor, pushes it to a local registry container, and verifies it with stock cosign. It runs on every PR and on main, non-blocking: staging has no SLO guarantee and re-signs its TUF metadata every few days, so its outages must not gate merges. Also document the push-then-sign ordering, which leaves an artifact published but unsigned if signing fails after the upload succeeds. Signed-off-by: Samuele Verzi <samu@stacklok.com>
GitHub validates a called workflow's declared permissions against the calling job's statically, before inputs are evaluated, so the build-only callers must grant id-token: write even though only the release caller ever reaches the signing code. Without it the whole PR Checks run fails at startup rather than the job simply running unprivileged. Signed-off-by: Samuele Verzi <samu@stacklok.com>
samuv
force-pushed
the
skills-keyless/04-ci-and-staging-e2e
branch
from
August 21, 2026 08:29
07de929 to
aaa60ef
Compare
sigstore-go tests the certificate SAN with Regexp.MatchString, which is unanchored, so the previous pattern accepted any SAN merely containing the expected workflow identity. Anchor it and escape the literal dots, since proving the *correct* identity round-trips is the whole point of the job. Also correct the architecture doc: the job skips fork PRs, which cannot be granted the id-token permission the ambient token needs. Signed-off-by: Samuele Verzi <samu@stacklok.com>
The build runs repository code — thv serve and thv skill build — which on a pull request is code from the pull request itself. Because GitHub validates a called workflow's permissions against its caller's statically, a single workflow behind a push: input forced every caller to grant the union, so PR CI held the id-token: write that signs releases. That contradicts the rule run-on-pr.yml already states for the test job: a job executing repository code has no business holding release signing keys. Publishing now lives in skills-publish.yml, called only from releaser.yml, and pull requests and main call a build-only skills-build.yml that holds neither packages: write nor id-token: write. Renaming the publishing workflow is free right now and would not stay that way: the signing certificate identity is the workflow path, and no skill has ever been published signed, so no consumer has recorded provenance pointing at the old name. Signed-off-by: Samuele Verzi <samu@stacklok.com>
The job needs id-token: write to mint an ambient OIDC token, and it builds and runs repository code to use it — on a pull request, code from that pull request. It could not impersonate the release signer, since a different workflow path yields a different certificate SAN and lock provenance pins the path, but the request variables in its environment let that code mint a token for any other audience, which any external trust policy matching this repository without pinning the ref would accept. It was already non-blocking, so running it post-merge from run-on-main.yml costs no merge gating. Dispatching "Main build" on a branch still exercises it on demand. Signed-off-by: Samuele Verzi <samu@stacklok.com>
rdimitrov
approved these changes
Aug 21, 2026
15 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Final PR of the keyless skill-signing stack (#6383 → #6385 → #6390 → this).
skills-build-and-publish.ymlcarried a--no-signstopgap with aTODO(#6307), added while the signing path was still being built. The signer, the server-side token plumbing, and the CLI credential ladder all exist now, so the stopgap is removed: a barethv skill pushpicks up the job's ambient OIDC token and signs against Fulcio and Rekor. This requiresid-token: writeon both the reusable workflow's job and onreleaser.yml's calling job — GitHub caps a reusable workflow's permissions at what its caller declares, so granting it in one place only would silently yield no token.skills-keyless-signing-e2e.ymlsigns a throwaway skill against Sigstore's staging Fulcio and Rekor, pushes it to a localregistry:2service container, and verifies it with stockcosign. A signature only ToolHive can read fails the job.continue-on-errorsits on the called workflow's own job, because GitHub rejects that key on a job that usesuses:.Closes #6307
Type of change
Test plan
task test)task test-e2e)task lint-fix)No Go code changes in this PR, so there is nothing new for the Go suites to cover. What was actually run:
actionlinton all five workflows — clean. It caught one real error during development:continue-on-erroris not permitted on a job that calls a reusable workflow, which is why it now lives on the inner job.zizmor 1.29.0 --persona regular --min-severity medium(the exact gate fromsecurity-scan.yml) over.github/— no findings.codespellwith the repo's.codespellrcover every changed file — clean.task test: one failure,TestMCPGoClientInitializeAndPinginpkg/transport/proxy/streamable, from a stray local process holding port 8096. Untouched package, unrelated to this PR.task lint-fix: fails locally with thenilness/sentrypanic that 39a85c8 pinned CI away from — my local golangci-lint is v2.13.0, CI pins v2.12.2. No Go files change here.Changes
.github/workflows/skills-build-and-publish.yml--no-signfrom both pushes; addid-token: write.github/workflows/releaser.ymlid-token: writeto the only job that pushes withpush: true.github/workflows/skills-keyless-signing-e2e.yml.github/workflows/run-on-pr.ymlid-token: write.github/workflows/run-on-main.ymldocs/arch/12-skills-system.mdDoes this introduce a user-facing change?
Yes. Skills published by the release pipeline are now Sigstore-signed rather than unsigned, so project-scoped installs of them no longer need
--allow-unsigned.Special notes for reviewers
This PR is stacked on #6390 and must not merge before it. The dependency is semantic, not textual: on
maintoday there is no--identity-tokenflag and no acquisition ladder, so a barethv skill pushis rejected with a 400signing credential required. Dropping--no-signonly works once #6390 lands. GitHub will retarget this tomainautomatically when that happens.Two implementation details that are non-obvious and were found by reading the dependency's source rather than by guessing:
TOOLHIVE_DEV=trueon thethv serveprocess is mandatory for the local registry.newOCIRegistryClient(pkg/api/server.go) only uses plain HTTP for loopback registries in dev mode; without it the oras push attempts TLS against the no-TLS container and dies in the handshake before signing is reached. The signature manifest is attached by go-containerregistry, which independently picks HTTP for alocalhost:<port>host — which is why the reference islocalhost:5000/...and not a barelocalhosthost, where the two halves would disagree on scheme.--new-bundle-format=falseoncosign verifyis required. cosign v3 defaults it totrue(OCI 1.1 referrers bundle), buttoolhive-coreattaches the classic cosign signature manifest — asha256-<hex>.sigtag whose layer annotations carry signature, certificate and Rekor entry. With the default left on, verification finds no signature at all, which would read as a signing bug rather than a format mismatch.Two things researched from Sigstore's docs and cosign v3.0.6's source but not confirmable without a live run — expect to iterate once on CI logs:
sigstoreaudience exactly as production does. Staging is documented as operated the same way, so this is likely fine.cosign verifyaftercosign initialize --stagingresolves the staging Rekor key purely from the pinned trust root, with no explicit--rekor-url. This repo has no prior staging-Sigstore usage to confirm against.releaser.yml's change is inert until an actual release runs, so it can only be smoke-tested by inspection here.Unrelated pre-existing issue noticed while linting, not fixed here to keep scope clean:
releaser.yml'snotify-release-failurereferencesneeds.extract-release-actor, a job that does not exist, so that Slack field always falls back togithub.actor.Generated with Claude Code