From 079fccbda25c634b20f9f382331d3fb5ac6d6392 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Thu, 20 Aug 2026 16:15:06 +0200 Subject: [PATCH 1/5] Enable keyless signing for CI skill pushes 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 --- .github/workflows/releaser.yml | 1 + .github/workflows/run-on-main.yml | 13 +- .github/workflows/run-on-pr.yml | 14 ++ .../workflows/skills-build-and-publish.yml | 18 +- .../workflows/skills-keyless-signing-e2e.yml | 218 ++++++++++++++++++ docs/arch/12-skills-system.md | 31 ++- 6 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/skills-keyless-signing-e2e.yml diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index 2344dac9ce..963fd648ef 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -279,6 +279,7 @@ jobs: permissions: contents: read packages: write + id-token: write # To do keyless signing of the pushed skill artifacts uses: ./.github/workflows/skills-build-and-publish.yml with: push: true diff --git a/.github/workflows/run-on-main.yml b/.github/workflows/run-on-main.yml index 6f71c343ca..1caaebf953 100644 --- a/.github/workflows/run-on-main.yml +++ b/.github/workflows/run-on-main.yml @@ -59,4 +59,15 @@ jobs: permissions: contents: read packages: write - uses: ./.github/workflows/skills-build-and-publish.yml \ No newline at end of file + uses: ./.github/workflows/skills-build-and-publish.yml + skills-keyless-e2e: + name: Skills Keyless Signing E2E (staging) + needs: [linting, tests, codegen] + # Informational, not a merge gate: it signs against Sigstore's public + # staging instance, whose outages this repository does not control. The + # continue-on-error that makes it non-blocking lives on the called + # workflow's own job — GitHub rejects that key on a job that uses `uses:`. + permissions: + contents: read + id-token: write + uses: ./.github/workflows/skills-keyless-signing-e2e.yml diff --git a/.github/workflows/run-on-pr.yml b/.github/workflows/run-on-pr.yml index 562ea79569..fcaa73ae0a 100644 --- a/.github/workflows/run-on-pr.yml +++ b/.github/workflows/run-on-pr.yml @@ -55,3 +55,17 @@ jobs: contents: read packages: write uses: ./.github/workflows/skills-build-and-publish.yml + skills-keyless-e2e: + name: Skills Keyless Signing E2E (staging) + needs: [linting, tests, codegen] + # Fork PRs cannot be granted id-token: write, so there is no ambient + # token to sign with and the job could only ever fail. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # Informational, not a merge gate: it signs against Sigstore's public + # staging instance, whose outages this repository does not control. The + # continue-on-error that makes it non-blocking lives on the called + # workflow's own job — GitHub rejects that key on a job that uses `uses:`. + permissions: + contents: read + id-token: write + uses: ./.github/workflows/skills-keyless-signing-e2e.yml diff --git a/.github/workflows/skills-build-and-publish.yml b/.github/workflows/skills-build-and-publish.yml index 337548bc2e..2081bd1926 100644 --- a/.github/workflows/skills-build-and-publish.yml +++ b/.github/workflows/skills-build-and-publish.yml @@ -33,9 +33,13 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # packages:write is only exercised when inputs.push is true, - # but GitHub Actions does not support conditional permissions. + # packages:write and id-token:write are only exercised when inputs.push + # is true, but GitHub Actions does not support conditional permissions. packages: write + # Lets `thv skill push` mint the ambient OIDC token it signs with. A + # reusable workflow cannot be granted more than its caller declares, so + # the calling job needs this too (releaser.yml's skills-build-and-push). + id-token: write env: BASE_REPO: "ghcr.io/stacklok/toolhive/skills" @@ -109,16 +113,18 @@ jobs: if [ "$PUSH" = "true" ]; then echo "Pushing skill: ${built_ref}" - # TODO(#6307): pushes are unsigned until keyless push signing lands; - # switch to Fulcio/Rekor signing and drop --no-sign once that ships. - ./thv skill push "$built_ref" --no-sign + # No signing flags: with id-token:write granted, the push acquires + # the job's ambient OIDC token and signs keylessly against Fulcio + # and Rekor. Absent that token the push fails rather than + # publishing unsigned. + ./thv skill push "$built_ref" # Also tag as latest when building from a release tag if [[ "$GH_REF" == refs/tags/* ]]; then latest_ref="${BASE_REPO}/${skill_name}:latest" echo "Tagging as latest: ${latest_ref}" built_latest=$(./thv skill build "$skill_dir" --tag "$latest_ref") - ./thv skill push "$built_latest" --no-sign + ./thv skill push "$built_latest" fi echo "Published: ${ref}" diff --git a/.github/workflows/skills-keyless-signing-e2e.yml b/.github/workflows/skills-keyless-signing-e2e.yml new file mode 100644 index 0000000000..d69a68214a --- /dev/null +++ b/.github/workflows/skills-keyless-signing-e2e.yml @@ -0,0 +1,218 @@ +# +# Copyright 2025 Stacklok, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Interoperability check for keyless skill signing (#6307). +# +# `thv skill push` with no signing flags acquires the job's ambient OIDC +# token, and `thv serve` exchanges it with Fulcio for a certificate and +# records the signature in Rekor. This workflow proves that what comes out +# the other end is a real Sigstore signature and not merely something +# ToolHive's own verifier happens to accept: it signs a throwaway skill and +# then verifies it with stock `cosign`, an entirely separate implementation. +# +# Two deliberate choices keep this off the production trust root: +# +# - Signing targets Sigstore's *staging* Fulcio and Rekor, so nothing this +# job produces lands in the production transparency log. Staging is +# operated the same way as production but carries no SLO guarantee. +# - The artifact is pushed to a throwaway `registry:2` service container, +# never to a real registry. +# +# Callers run this non-blocking (continue-on-error) for the same reason: +# staging outages and its frequently re-signed TUF metadata are outside this +# repository's control. + +name: Skills Keyless Signing E2E + +on: + workflow_call: + +jobs: + keyless-signing-e2e: + name: Sign against Sigstore staging and verify with cosign + runs-on: ubuntu-latest + # Non-blocking: staging carries no SLO guarantee and re-signs its TUF + # metadata every few days, so an outage there must not fail a caller's + # run. A failure is still recorded against this job for anyone reading + # the run. This lives here rather than on the calling job because GitHub + # rejects continue-on-error on a job that calls a reusable workflow. + continue-on-error: true + permissions: + contents: read + # The whole point: without this, no ambient OIDC token exists and the + # push fails rather than falling back to an unsigned publish. + id-token: write + + services: + # Throwaway registry. Nothing published here outlives the job, so it + # runs over plain HTTP with no auth. + registry: + image: registry:2@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 + ports: + - 5000:5000 + + env: + # Sigstore staging. Read by `thv serve` (pkg/skills/skillsvc/build.go), + # not by the CLI, so they are exported onto the server process below. + STAGING_FULCIO_URL: https://fulcio.sigstage.dev + STAGING_REKOR_URL: https://rekor.sigstage.dev + SKILL_NAME: keyless-e2e + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + # This job builds and signs throwaway content; it never pushes to + # the repository. + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: 'stable' + + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Pin the Sigstore staging trust root + # Points cosign's TUF trust root at sigstage, so the verify step below + # trusts the staging Fulcio CA and Rekor key instead of production's. + # Staging re-signs its metadata every few days; expired metadata here + # is the most likely reason for this job to fail spuriously. + run: cosign initialize --staging + + - name: Build thv binary + run: go build -o ./thv ./cmd/thv + + - name: Start thv serve + env: + # All three are read by the server process, never by the CLI. + # + # TOOLHIVE_DEV makes the OCI push use plain HTTP for loopback + # registries only (pkg/api/server.go newOCIRegistryClient); without + # it the push attempts TLS against the service container and dies in + # the handshake, long before signing. The signature manifest itself + # is attached by go-containerregistry, which already picks HTTP for + # a `localhost:` host with no configuration — hence the + # `localhost:5000` reference below rather than a bare `localhost` + # host, which the two halves would resolve to different schemes. + TOOLHIVE_DEV: 'true' + TOOLHIVE_SIGSTORE_FULCIO_URL: ${{ env.STAGING_FULCIO_URL }} + TOOLHIVE_SIGSTORE_REKOR_URL: ${{ env.STAGING_REKOR_URL }} + run: | + ./thv serve --host 127.0.0.1 --port 8080 > /tmp/thv-serve.log 2>&1 & + echo "THV_PID=$!" >> "$GITHUB_ENV" + + # Wait for the server to be ready + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/health > /dev/null 2>&1; then + echo "thv serve is ready (PID: $!)" + break + fi + if [ "$i" -eq 30 ]; then + echo "thv serve failed to start after 30s; logs:" + cat /tmp/thv-serve.log + exit 1 + fi + sleep 1 + done + + # Verify process is still alive after health check + kill -0 "$!" 2>/dev/null || { echo "thv serve exited unexpectedly; logs:"; cat /tmp/thv-serve.log; exit 1; } + + - name: Build a throwaway skill + id: build + env: + # github.run_id and github.run_attempt are digits only, but they are + # bound rather than interpolated to match how every other workflow + # here feeds context values into a shell. + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + + skill_dir="$(mktemp -d)/${SKILL_NAME}" + mkdir -p "$skill_dir" + cat > "${skill_dir}/SKILL.md" <> "$GITHUB_OUTPUT" + + - name: Push and sign keylessly + env: + REF: ${{ steps.build.outputs.ref }} + run: | + set -euo pipefail + # No signing flags. The CLI's acquisition ladder + # (pkg/skills/identitytoken) finds ACTIONS_ID_TOKEN_REQUEST_URL / + # _TOKEN, which exist because this job declares id-token: write. + ./thv skill push "$REF" + + - name: Verify the signature with stock cosign + env: + REF: ${{ steps.build.outputs.ref }} + # Fulcio records the *reusable* workflow's ref in the certificate + # SAN (the token's job_workflow_ref claim), so this pins the + # identity to this file rather than to whichever workflow called it. + # The @.* tail is the git ref, which differs per branch, tag and PR. + IDENTITY_REGEXP: https://github.com/${{ github.repository }}/.github/workflows/skills-keyless-signing-e2e.yml@.* + run: | + set -euo pipefail + # A loose identity pattern would still pass if the wrong certificate + # somehow signed this, which would defeat the purpose of the job. + # --new-bundle-format=false: cosign v3 defaults to expecting an OCI + # 1.1 referrers bundle, but toolhive-core attaches the classic + # cosign signature manifest — a `sha256-.sig` tag whose layer + # annotations carry the signature, certificate and Rekor entry + # (container/signer/cosign_attach.go). Leaving the default on makes + # verification fail to find the signature at all, which would look + # like a signing bug rather than a format mismatch. + cosign verify "$REF" \ + --certificate-identity-regexp "$IDENTITY_REGEXP" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --new-bundle-format=false \ + --allow-insecure-registry + + - name: Report failure + if: failure() + run: | + { + echo "## Skills keyless signing E2E failed" + echo + echo "This job is informational and does not block the run." + echo "Most likely causes, in order: stale Sigstore staging TUF" + echo "metadata (re-signed every few days), a staging Fulcio or" + echo "Rekor outage, or a genuine regression in keyless push." + } >> "$GITHUB_STEP_SUMMARY" + echo "--- thv serve log ---" + cat /tmp/thv-serve.log + + - name: Stop thv serve + if: always() + run: kill "$THV_PID" 2>/dev/null || true diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index 51a88c5550..14614c3adf 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -221,6 +221,35 @@ leaves an unsigned artifact published: once resolved, is always forwarded even alongside `--key` so the server reports the conflict rather than the client silently picking one. +**Push happens before signing.** `skillsvc.Push` uploads the artifact to the +registry first, then signs it and attaches the signature manifest. A signing +failure therefore returns an error *after* the artifact is already published, +leaving it live and unsigned in the registry — the acquisition ladder above +narrows the window (a missing credential fails before anything is pushed) but +does not close it, since Fulcio and Rekor can still fail once the upload has +happened. Recovering means re-running the push, which re-signs the same +digest. This ordering predates keyless signing and is unchanged by it; +consumers are protected by the install-side requirement for a valid +signature, not by the publisher's ordering. + +**In CI:** release pushes (`.github/workflows/skills-build-and-publish.yml`, +called with `push: true` only from `releaser.yml`) run `thv skill push` with +no signing flags at all, so the ambient rung of the ladder signs them with +the job's OIDC token. That needs `id-token: write` on both the reusable +workflow's job *and* the calling job — GitHub caps a reusable workflow's +permissions at what its caller declares, so granting it in only one place +silently yields no token and fails the push. + +Interoperability with the wider Sigstore ecosystem is covered separately by +`.github/workflows/skills-keyless-signing-e2e.yml`, which signs a throwaway +skill against Sigstore's *staging* Fulcio and Rekor (via the +`TOOLHIVE_SIGSTORE_FULCIO_URL` / `TOOLHIVE_SIGSTORE_REKOR_URL` overrides read +by `thv serve`) and then verifies it with stock `cosign verify` rather than +ToolHive's own verifier — so a signature that only ToolHive can read fails +the job. It runs on every PR and on `main`, non-blocking: staging carries no +SLO guarantee and re-signs its TUF metadata every few days, so its outages +are reported without gating merges. + ### 4. Installation ```bash @@ -373,7 +402,7 @@ What is still trusted on faith, deliberately and visibly: - **The lock file itself** remains a repository-editable policy document. A diff converting a `provenance:` block to `unsigned: true` is a trust downgrade that sync will honor — it cannot happen without a lock file edit, which is exactly what lock-file review must catch. Reviewing `provenance`, `unsigned`, `digest`, and `resolvedReference` changes carries the same weight as reviewing the AI-executed skill content itself. - **First use** anchors trust to whatever identity signed the artifact at that moment; verify the printed identity is the publisher you expect. -Publishing is signed by default: `thv skill push` requires either `--key` (a cosign private key; the signature manifest is attached next to the artifact and the bundle is retrievable at install) or an explicit `--no-sign`. +Publishing is signed by default: `thv skill push` requires `--key` (a cosign private key), an OIDC identity token for keyless signing (supplied with `--identity-token` or acquired automatically), or an explicit `--no-sign`. Either signing path attaches the signature manifest next to the artifact, and the bundle is retrievable at install. See [Publishing](#3-publishing) for the full ladder. ### Schema From aaa60efb7948fa337bca5adc6092ae67e787f3a2 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Thu, 20 Aug 2026 16:25:55 +0200 Subject: [PATCH 2/5] Grant id-token to build-only skills callers 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 --- .github/workflows/run-on-main.yml | 5 +++++ .github/workflows/run-on-pr.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/run-on-main.yml b/.github/workflows/run-on-main.yml index 1caaebf953..046e5ebdab 100644 --- a/.github/workflows/run-on-main.yml +++ b/.github/workflows/run-on-main.yml @@ -59,6 +59,11 @@ jobs: permissions: contents: read packages: write + # Unused here — these callers build without pushing — but GitHub + # validates a called workflow's permissions against the calling job's + # statically, before inputs are considered, so omitting it fails the + # whole run at startup. Same reason packages:write is granted above. + id-token: write uses: ./.github/workflows/skills-build-and-publish.yml skills-keyless-e2e: name: Skills Keyless Signing E2E (staging) diff --git a/.github/workflows/run-on-pr.yml b/.github/workflows/run-on-pr.yml index fcaa73ae0a..d92a0f7648 100644 --- a/.github/workflows/run-on-pr.yml +++ b/.github/workflows/run-on-pr.yml @@ -54,6 +54,11 @@ jobs: permissions: contents: read packages: write + # Unused here — these callers build without pushing — but GitHub + # validates a called workflow's permissions against the calling job's + # statically, before inputs are considered, so omitting it fails the + # whole run at startup. Same reason packages:write is granted above. + id-token: write uses: ./.github/workflows/skills-build-and-publish.yml skills-keyless-e2e: name: Skills Keyless Signing E2E (staging) From 27c98ea1b39be214be12908816c26adecd170696 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 21 Aug 2026 11:07:35 +0200 Subject: [PATCH 3/5] Anchor the keyless E2E certificate identity match 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 --- .github/workflows/skills-keyless-signing-e2e.yml | 7 ++++++- docs/arch/12-skills-system.md | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/skills-keyless-signing-e2e.yml b/.github/workflows/skills-keyless-signing-e2e.yml index d69a68214a..9e7510c64a 100644 --- a/.github/workflows/skills-keyless-signing-e2e.yml +++ b/.github/workflows/skills-keyless-signing-e2e.yml @@ -181,7 +181,12 @@ jobs: # SAN (the token's job_workflow_ref claim), so this pins the # identity to this file rather than to whichever workflow called it. # The @.* tail is the git ref, which differs per branch, tag and PR. - IDENTITY_REGEXP: https://github.com/${{ github.repository }}/.github/workflows/skills-keyless-signing-e2e.yml@.* + # + # Anchored and dot-escaped because the match is unanchored: + # sigstore-go tests the SAN with Regexp.MatchString + # (pkg/verify/certificate_identity.go), so an unanchored pattern + # accepts any SAN that merely *contains* it. + IDENTITY_REGEXP: ^https://github\.com/${{ github.repository }}/\.github/workflows/skills-keyless-signing-e2e\.yml@.*$ run: | set -euo pipefail # A loose identity pattern would still pass if the wrong certificate diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index 14614c3adf..95ef0ae09f 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -246,9 +246,10 @@ skill against Sigstore's *staging* Fulcio and Rekor (via the `TOOLHIVE_SIGSTORE_FULCIO_URL` / `TOOLHIVE_SIGSTORE_REKOR_URL` overrides read by `thv serve`) and then verifies it with stock `cosign verify` rather than ToolHive's own verifier — so a signature that only ToolHive can read fails -the job. It runs on every PR and on `main`, non-blocking: staging carries no -SLO guarantee and re-signs its TUF metadata every few days, so its outages -are reported without gating merges. +the job. It runs on every same-repo PR and on `main` — fork PRs skip it, +since they cannot be granted the `id-token: write` the ambient token needs — +and is non-blocking: staging carries no SLO guarantee and re-signs its TUF +metadata every few days, so its outages are reported without gating merges. ### 4. Installation From 069de0a9eb66d3320d12dc666dc57be51ec052b2 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 21 Aug 2026 11:27:54 +0200 Subject: [PATCH 4/5] Split skills build and publish into separate workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/releaser.yml | 10 +- .github/workflows/run-on-main.yml | 14 +-- .github/workflows/run-on-pr.yml | 12 +- .github/workflows/skills-build.yml | 107 ++++++++++++++++++ ...ild-and-publish.yml => skills-publish.yml} | 66 +++++------ docs/arch/12-skills-system.md | 23 ++-- 6 files changed, 167 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/skills-build.yml rename .github/workflows/{skills-build-and-publish.yml => skills-publish.yml} (67%) diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index 963fd648ef..fb5e18d49f 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -273,16 +273,14 @@ jobs: id-token: write uses: ./.github/workflows/image-build-and-publish.yml - skills-build-and-push: - name: Build and Publish Skills + skills-publish: + name: Publish Skills needs: [ release-binaries ] permissions: contents: read packages: write id-token: write # To do keyless signing of the pushed skill artifacts - uses: ./.github/workflows/skills-build-and-publish.yml - with: - push: true + uses: ./.github/workflows/skills-publish.yml publish-helm: name: Publish Helm Chart @@ -352,7 +350,7 @@ jobs: - compute-build-flags - release-binaries - image-build-and-push - - skills-build-and-push + - skills-publish - publish-helm if: ${{ failure() }} runs-on: ubuntu-slim diff --git a/.github/workflows/run-on-main.yml b/.github/workflows/run-on-main.yml index 046e5ebdab..a7d9418bf5 100644 --- a/.github/workflows/run-on-main.yml +++ b/.github/workflows/run-on-main.yml @@ -53,18 +53,14 @@ jobs: packages: write id-token: write uses: ./.github/workflows/image-build-and-publish.yml - skills-build-and-push: - name: Build and Publish Skills + # Named for what it does: this verifies skills still build. Publishing + # happens only from releaser.yml, via skills-publish.yml. + skills-build: + name: Build Skills needs: [linting, tests, codegen] permissions: contents: read - packages: write - # Unused here — these callers build without pushing — but GitHub - # validates a called workflow's permissions against the calling job's - # statically, before inputs are considered, so omitting it fails the - # whole run at startup. Same reason packages:write is granted above. - id-token: write - uses: ./.github/workflows/skills-build-and-publish.yml + uses: ./.github/workflows/skills-build.yml skills-keyless-e2e: name: Skills Keyless Signing E2E (staging) needs: [linting, tests, codegen] diff --git a/.github/workflows/run-on-pr.yml b/.github/workflows/run-on-pr.yml index d92a0f7648..3fea83b50e 100644 --- a/.github/workflows/run-on-pr.yml +++ b/.github/workflows/run-on-pr.yml @@ -51,15 +51,13 @@ jobs: skills-build: name: Build Skills needs: [linting, tests, codegen] + # Build-only, and deliberately calls a workflow that publishes nothing: + # this executes code from the pull request, so it must not hold the + # packages:write or id-token:write that skills-publish.yml uses to sign + # and push a release. permissions: contents: read - packages: write - # Unused here — these callers build without pushing — but GitHub - # validates a called workflow's permissions against the calling job's - # statically, before inputs are considered, so omitting it fails the - # whole run at startup. Same reason packages:write is granted above. - id-token: write - uses: ./.github/workflows/skills-build-and-publish.yml + uses: ./.github/workflows/skills-build.yml skills-keyless-e2e: name: Skills Keyless Signing E2E (staging) needs: [linting, tests, codegen] diff --git a/.github/workflows/skills-build.yml b/.github/workflows/skills-build.yml new file mode 100644 index 0000000000..728be9b605 --- /dev/null +++ b/.github/workflows/skills-build.yml @@ -0,0 +1,107 @@ +# +# Copyright 2025 Stacklok, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Verification-only build of the distributable Claude Code Agent Skills in +# skills/ — it proves every skill still packages cleanly, and publishes +# nothing. +# +# This is deliberately a separate workflow from skills-publish.yml rather +# than the same one behind a `push: false` input. The build runs repository +# code (`thv serve`, `thv skill build`), which on a pull request is code +# from the pull request itself; keeping publishing in its own file means +# this job never needs packages: write or id-token: write, so a pull request +# cannot reach the credentials that sign a release. GitHub validates a +# called workflow's permissions against its caller's statically, so a shared +# workflow would have forced every caller to grant the union regardless of +# the input. + +name: Build Skills + +on: + workflow_call: + +jobs: + skills-build: + name: Build Skills + runs-on: ubuntu-latest + permissions: + contents: read + + env: + BASE_REPO: "ghcr.io/stacklok/toolhive/skills" + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: 'stable' + + - name: Compute version number + id: version-string + uses: ./.github/actions/compute-version + + - name: Build thv binary + run: go build -o ./thv ./cmd/thv + + - name: Start thv serve + run: | + ./thv serve --host 127.0.0.1 --port 8080 > /tmp/thv-serve.log 2>&1 & + echo "THV_PID=$!" >> "$GITHUB_ENV" + + # Wait for the server to be ready + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/health > /dev/null 2>&1; then + echo "thv serve is ready (PID: $!)" + break + fi + if [ "$i" -eq 30 ]; then + echo "thv serve failed to start after 30s; logs:" + cat /tmp/thv-serve.log + exit 1 + fi + sleep 1 + done + + # Verify process is still alive after health check + kill -0 "$!" 2>/dev/null || { echo "thv serve exited unexpectedly; logs:"; cat /tmp/thv-serve.log; exit 1; } + + - name: Build skills + env: + TAG: ${{ steps.version-string.outputs.tag }} + run: | + set -euo pipefail + + for skill_dir in skills/*/; do + # Skip if no skills exist + [ -d "$skill_dir" ] || continue + + skill_name=$(basename "$skill_dir") + # Tagged with the real destination even though nothing is pushed, + # so the reference is validated exactly as a release would build it. + ref="${BASE_REPO}/${skill_name}:${TAG}" + + echo "Building skill: ${skill_name} -> ${ref}" + built_ref=$(./thv skill build "$skill_dir" --tag "$ref") + echo "Built: ${built_ref}" + done + + - name: Stop thv serve + if: always() + run: kill "$THV_PID" 2>/dev/null || true diff --git a/.github/workflows/skills-build-and-publish.yml b/.github/workflows/skills-publish.yml similarity index 67% rename from .github/workflows/skills-build-and-publish.yml rename to .github/workflows/skills-publish.yml index 2081bd1926..4748ac1d8b 100644 --- a/.github/workflows/skills-build-and-publish.yml +++ b/.github/workflows/skills-publish.yml @@ -13,32 +13,32 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# This workflow builds distributable Claude Code Agent Skills from -# skills/ and optionally pushes them as OCI artifacts to GHCR. +# This workflow builds distributable Claude Code Agent Skills from skills/ +# and publishes them as signed OCI artifacts to GHCR. +# +# Deliberately separate from skills-build.yml, which performs the same build +# for verification on pull requests and main. This one holds id-token: write +# so `thv skill push` can sign keylessly, and it runs repository code +# (`thv serve`, `thv skill build`) to do so — so it must only ever be called +# from a trusted, post-merge context. releaser.yml is its sole caller; adding +# a pull-request-triggered one would hand OIDC signing capability to code +# from the pull request under review. -name: Build and Publish Skills +name: Publish Skills on: workflow_call: - inputs: - push: - description: "Push built skills to the registry" - required: false - default: false - type: boolean jobs: - skills-build-and-publish: - name: Build and Publish Skills + skills-publish: + name: Publish Skills runs-on: ubuntu-latest permissions: contents: read - # packages:write and id-token:write are only exercised when inputs.push - # is true, but GitHub Actions does not support conditional permissions. packages: write # Lets `thv skill push` mint the ambient OIDC token it signs with. A # reusable workflow cannot be granted more than its caller declares, so - # the calling job needs this too (releaser.yml's skills-build-and-push). + # the calling job needs this too (releaser.yml's skills-publish). id-token: write env: @@ -63,7 +63,6 @@ jobs: run: go build -o ./thv ./cmd/thv - name: Login to GitHub Container Registry - if: inputs.push uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io @@ -92,10 +91,9 @@ jobs: # Verify process is still alive after health check kill -0 "$!" 2>/dev/null || { echo "thv serve exited unexpectedly; logs:"; cat /tmp/thv-serve.log; exit 1; } - - name: Build skills + - name: Build and publish skills env: TAG: ${{ steps.version-string.outputs.tag }} - PUSH: ${{ inputs.push }} GH_REF: ${{ github.ref }} run: | set -euo pipefail @@ -111,26 +109,22 @@ jobs: built_ref=$(./thv skill build "$skill_dir" --tag "$ref") echo "Built: ${built_ref}" - if [ "$PUSH" = "true" ]; then - echo "Pushing skill: ${built_ref}" - # No signing flags: with id-token:write granted, the push acquires - # the job's ambient OIDC token and signs keylessly against Fulcio - # and Rekor. Absent that token the push fails rather than - # publishing unsigned. - ./thv skill push "$built_ref" - - # Also tag as latest when building from a release tag - if [[ "$GH_REF" == refs/tags/* ]]; then - latest_ref="${BASE_REPO}/${skill_name}:latest" - echo "Tagging as latest: ${latest_ref}" - built_latest=$(./thv skill build "$skill_dir" --tag "$latest_ref") - ./thv skill push "$built_latest" - fi - - echo "Published: ${ref}" - else - echo "Skipping push (build-only mode)" + echo "Pushing skill: ${built_ref}" + # No signing flags: with id-token:write granted, the push acquires + # the job's ambient OIDC token and signs keylessly against Fulcio + # and Rekor. Absent that token the push fails rather than + # publishing unsigned. + ./thv skill push "$built_ref" + + # Also tag as latest when building from a release tag + if [[ "$GH_REF" == refs/tags/* ]]; then + latest_ref="${BASE_REPO}/${skill_name}:latest" + echo "Tagging as latest: ${latest_ref}" + built_latest=$(./thv skill build "$skill_dir" --tag "$latest_ref") + ./thv skill push "$built_latest" fi + + echo "Published: ${ref}" done - name: Stop thv serve diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index 95ef0ae09f..94255bd59b 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -232,13 +232,22 @@ digest. This ordering predates keyless signing and is unchanged by it; consumers are protected by the install-side requirement for a valid signature, not by the publisher's ordering. -**In CI:** release pushes (`.github/workflows/skills-build-and-publish.yml`, -called with `push: true` only from `releaser.yml`) run `thv skill push` with -no signing flags at all, so the ambient rung of the ladder signs them with -the job's OIDC token. That needs `id-token: write` on both the reusable -workflow's job *and* the calling job — GitHub caps a reusable workflow's -permissions at what its caller declares, so granting it in only one place -silently yields no token and fails the push. +**In CI:** release pushes (`.github/workflows/skills-publish.yml`, called +only from `releaser.yml`) run `thv skill push` with no signing flags at all, +so the ambient rung of the ladder signs them with the job's OIDC token. That +needs `id-token: write` on both the reusable workflow's job *and* the calling +job — GitHub caps a reusable workflow's permissions at what its caller +declares, so granting it in only one place silently yields no token and fails +the push. + +Building is a separate workflow (`skills-build.yml`, called from +`run-on-pr.yml` and `run-on-main.yml`) that publishes nothing and holds +neither `packages: write` nor `id-token: write`. The split is the reason +those permissions exist in one place only: the build executes repository +code — `thv serve` and `thv skill build` — which on a pull request is code +from the pull request itself, and GitHub validates a called workflow's +permissions against its caller's statically, so a single workflow behind a +`push:` input would have forced every caller to grant the union regardless. Interoperability with the wider Sigstore ecosystem is covered separately by `.github/workflows/skills-keyless-signing-e2e.yml`, which signs a throwaway From 4f2d37009aa94467c59c62ce4c590b93e55f2d67 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 21 Aug 2026 11:52:07 +0200 Subject: [PATCH 5/5] Run the keyless signing E2E only on merged code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/run-on-pr.yml | 26 +++++++++---------- .../workflows/skills-keyless-signing-e2e.yml | 11 +++++--- docs/arch/12-skills-system.md | 16 +++++++++--- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/.github/workflows/run-on-pr.yml b/.github/workflows/run-on-pr.yml index 3fea83b50e..96bd2ffffd 100644 --- a/.github/workflows/run-on-pr.yml +++ b/.github/workflows/run-on-pr.yml @@ -58,17 +58,15 @@ jobs: permissions: contents: read uses: ./.github/workflows/skills-build.yml - skills-keyless-e2e: - name: Skills Keyless Signing E2E (staging) - needs: [linting, tests, codegen] - # Fork PRs cannot be granted id-token: write, so there is no ambient - # token to sign with and the job could only ever fail. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - # Informational, not a merge gate: it signs against Sigstore's public - # staging instance, whose outages this repository does not control. The - # continue-on-error that makes it non-blocking lives on the called - # workflow's own job — GitHub rejects that key on a job that uses `uses:`. - permissions: - contents: read - id-token: write - uses: ./.github/workflows/skills-keyless-signing-e2e.yml + # Deliberately no skills-keyless-signing-e2e job here. It needs + # id-token: write to mint an ambient OIDC token, and it builds and runs + # repository code to use it — which on a pull request is code from the + # pull request. Even though the certificate it produces cannot impersonate + # the release signer (a different workflow path means a different SAN, and + # lock provenance pins that path), the job's environment carries + # ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN, from which that code could mint a + # token for *any* audience and exfiltrate it. Any external trust policy + # matching this repository without pinning the ref would accept it. The + # job runs post-merge from run-on-main.yml instead, where the code is + # trusted; it was already non-blocking, so it never gated a merge anyway. + # To exercise it on a branch, dispatch the "Main build" workflow there. diff --git a/.github/workflows/skills-keyless-signing-e2e.yml b/.github/workflows/skills-keyless-signing-e2e.yml index 9e7510c64a..c2ea8b9e13 100644 --- a/.github/workflows/skills-keyless-signing-e2e.yml +++ b/.github/workflows/skills-keyless-signing-e2e.yml @@ -30,9 +30,14 @@ # - The artifact is pushed to a throwaway `registry:2` service container, # never to a real registry. # -# Callers run this non-blocking (continue-on-error) for the same reason: -# staging outages and its frequently re-signed TUF metadata are outside this -# repository's control. +# It runs non-blocking (continue-on-error, on the job below) for the same +# reason: staging outages and its frequently re-signed TUF metadata are +# outside this repository's control. +# +# Called only from run-on-main.yml, never from run-on-pr.yml: it holds +# id-token: write and runs repository code, so on a pull request that code +# could mint an arbitrary-audience OIDC token from the request variables in +# its environment. Post-merge, that code is trusted. name: Skills Keyless Signing E2E diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index 94255bd59b..0a0cc8b121 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -255,10 +255,18 @@ skill against Sigstore's *staging* Fulcio and Rekor (via the `TOOLHIVE_SIGSTORE_FULCIO_URL` / `TOOLHIVE_SIGSTORE_REKOR_URL` overrides read by `thv serve`) and then verifies it with stock `cosign verify` rather than ToolHive's own verifier — so a signature that only ToolHive can read fails -the job. It runs on every same-repo PR and on `main` — fork PRs skip it, -since they cannot be granted the `id-token: write` the ambient token needs — -and is non-blocking: staging carries no SLO guarantee and re-signs its TUF -metadata every few days, so its outages are reported without gating merges. +the job. It runs post-merge on `main` only, and is non-blocking: staging +carries no SLO guarantee and re-signs its TUF metadata every few days, so its +outages are reported without gating anything. + +It is deliberately not wired into `run-on-pr.yml`. Minting the ambient OIDC +token needs `id-token: write`, and the job builds and runs repository code to +use it — which on a pull request is code from the pull request. That code +could not impersonate the release signer (a different workflow path yields a +different certificate SAN, and lock provenance pins the path), but it could +mint a token for any *other* audience from the request variables in its +environment. Running it only on merged code keeps that capability out of +reach; to exercise it on a branch, dispatch the "Main build" workflow there. ### 4. Installation