diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index 2344dac9ce..fb5e18d49f 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -273,15 +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 - uses: ./.github/workflows/skills-build-and-publish.yml - with: - push: true + id-token: write # To do keyless signing of the pushed skill artifacts + uses: ./.github/workflows/skills-publish.yml publish-helm: name: Publish Helm Chart @@ -351,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 6f71c343ca..a7d9418bf5 100644 --- a/.github/workflows/run-on-main.yml +++ b/.github/workflows/run-on-main.yml @@ -53,10 +53,22 @@ 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 - uses: ./.github/workflows/skills-build-and-publish.yml \ No newline at end of file + uses: ./.github/workflows/skills-build.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..96bd2ffffd 100644 --- a/.github/workflows/run-on-pr.yml +++ b/.github/workflows/run-on-pr.yml @@ -51,7 +51,22 @@ 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 - uses: ./.github/workflows/skills-build-and-publish.yml + uses: ./.github/workflows/skills-build.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-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-keyless-signing-e2e.yml b/.github/workflows/skills-keyless-signing-e2e.yml new file mode 100644 index 0000000000..c2ea8b9e13 --- /dev/null +++ b/.github/workflows/skills-keyless-signing-e2e.yml @@ -0,0 +1,228 @@ +# +# 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. +# +# 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 + +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. + # + # 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 + # 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/.github/workflows/skills-build-and-publish.yml b/.github/workflows/skills-publish.yml similarity index 64% rename from .github/workflows/skills-build-and-publish.yml rename to .github/workflows/skills-publish.yml index 337548bc2e..4748ac1d8b 100644 --- a/.github/workflows/skills-build-and-publish.yml +++ b/.github/workflows/skills-publish.yml @@ -13,29 +13,33 @@ # 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 is 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-publish). + id-token: write env: BASE_REPO: "ghcr.io/stacklok/toolhive/skills" @@ -59,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 @@ -88,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 @@ -107,24 +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}" - # 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 - - # 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 - 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 51a88c5550..0a0cc8b121 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -221,6 +221,53 @@ 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-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 +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 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 ```bash @@ -373,7 +420,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