From f54e164985f604764a67d3291fc78d323469e414 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:47:08 +0100 Subject: [PATCH 1/2] Bind expressions and drop credentials in CI workflows `${{ }}` is substituted into a run: block before the shell parses it, so the value becomes part of the command text rather than an argument to it. Binding to env: and referencing "$VAR" makes the shell treat it as data regardless of content. image-build-and-publish already bound VERSION in env: on four of its steps and then re-interpolated the same expression in the script; those now use "$VERSION". test-e2e-lifecycle was interpolating two values that were already shell variables, one from a workflow-level env: and one written to $GITHUB_ENV, so the expressions were redundant. Also stops these three workflows persisting the token in .git/config. All of them declare contents: read, so no job in them can push, which makes the credential unusable as well as unnecessary. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/image-build-and-publish.yml | 38 ++++++++++++++----- .github/workflows/release-notes.yml | 14 +++++-- .../workflows/skills-build-and-publish.yml | 2 + .github/workflows/test-e2e-lifecycle.yml | 8 ++-- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/.github/workflows/image-build-and-publish.yml b/.github/workflows/image-build-and-publish.yml index 9b50a38584..7a161d7892 100644 --- a/.github/workflows/image-build-and-publish.yml +++ b/.github/workflows/image-build-and-publish.yml @@ -18,6 +18,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 @@ -48,7 +50,7 @@ jobs: BUILD_DATE: ${{ github.event.head_commit.timestamp }} KO_CONFIG_PATH: ${{ github.workspace }}/.github/ko-ci.yml run: | - TAG=${{ steps.version-string.outputs.tag }} + TAG="$VERSION" TAGS="-t $TAG" # Add latest tag only if building from a tag @@ -62,8 +64,10 @@ jobs: - name: Sign Image with Cosign # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. + env: + VERSION: ${{ steps.version-string.outputs.tag }} run: | - TAG=${{ steps.version-string.outputs.tag }} + TAG="$VERSION" # Sign the ko image cosign sign -y $BASE_REPO:$TAG @@ -86,6 +90,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Compute version number id: version-string @@ -129,8 +135,10 @@ jobs: - name: Sign container image if: startsWith(github.ref, 'refs/tags/') + env: + VERSION: ${{ steps.version-string.outputs.tag }} run: | - TAG=${{ steps.version-string.outputs.tag }} + TAG="$VERSION" cosign sign -y $BASE_REPO:$TAG cosign sign -y $BASE_REPO:latest @@ -148,6 +156,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 @@ -190,7 +200,7 @@ jobs: BUILD_DATE: ${{ github.event.head_commit.timestamp }} KO_CONFIG_PATH: ${{ github.workspace }}/.github/ko-ci.yml run: | - TAG=${{ steps.version-string.outputs.tag }} + TAG="$VERSION" TAGS="-t $TAG" # Add latest tag only if building from a tag @@ -204,8 +214,10 @@ jobs: - name: Sign Image with Cosign # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. + env: + VERSION: ${{ steps.version-string.outputs.tag }} run: | - TAG=${{ steps.version-string.outputs.tag }} + TAG="$VERSION" # Sign the ko image cosign sign -y $BASE_REPO:$TAG @@ -228,6 +240,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 @@ -261,7 +275,7 @@ jobs: BUILD_DATE: ${{ github.event.head_commit.timestamp }} KO_CONFIG_PATH: ${{ github.workspace }}/.github/ko-ci.yml run: | - TAG=${{ steps.version-string.outputs.tag }} + TAG="$VERSION" TAGS="-t $TAG" # Add latest tag only if building from a tag if [[ "$GITHUB_REF" == refs/tags/* ]]; then @@ -273,8 +287,10 @@ jobs: - name: Sign Image with Cosign # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. + env: + VERSION: ${{ steps.version-string.outputs.tag }} run: | - TAG=${{ steps.version-string.outputs.tag }} + TAG="$VERSION" # Sign the ko image cosign sign -y $BASE_REPO:$TAG @@ -297,6 +313,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 @@ -339,7 +357,7 @@ jobs: BUILD_DATE: ${{ github.event.head_commit.timestamp }} KO_CONFIG_PATH: ${{ github.workspace }}/.github/ko-ci.yml run: | - TAG=$(echo "${{ steps.version-string.outputs.tag }}" | sed 's/+/_/g') + TAG=$(echo "$VERSION" | sed 's/+/_/g') TAGS="-t $TAG" # Add latest tag only if building from a tag @@ -353,8 +371,10 @@ jobs: - name: Sign Image with Cosign # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. + env: + VERSION: ${{ steps.version-string.outputs.tag }} run: | - TAG=$(echo "${{ steps.version-string.outputs.tag }}" | sed 's/+/_/g') + TAG=$(echo "$VERSION" | sed 's/+/_/g') # Sign the ko image cosign sign -y $BASE_REPO:$TAG diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index f7f2c2967d..185c03fc57 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -67,6 +67,7 @@ jobs: # Full history + tags are required: the skill lists/sorts tags and # compares ranges between the current and previous release. fetch-depth: 0 + persist-credentials: false - name: Resolve release tag id: tag @@ -137,8 +138,10 @@ jobs: - name: Verify notes were generated id: verify + env: + TAG: ${{ steps.tag.outputs.tag }} run: | - FILE="release-notes-${{ steps.tag.outputs.tag }}.md" + FILE="release-notes-${TAG}.md" if [ ! -s "$FILE" ]; then echo "::error::Expected $FILE was not generated by the release-notes skill" exit 1 @@ -170,19 +173,22 @@ jobs: if-no-files-found: error - name: Write notes to job summary + env: + TAG: ${{ steps.tag.outputs.tag }} + FILE: ${{ steps.verify.outputs.file }} run: | { - echo "## 📝 Generated release notes for \`${{ steps.tag.outputs.tag }}\`" + echo "## 📝 Generated release notes for \`${TAG}\`" echo "" echo "Review below, then copy-paste into the release or run:" echo "" echo '```' - echo "gh release edit ${{ steps.tag.outputs.tag }} --notes-file ${{ steps.verify.outputs.file }}" + echo "gh release edit ${TAG} --notes-file ${FILE}" echo '```' echo "" echo "---" echo "" - cat "${{ steps.verify.outputs.file }}" + cat "${FILE}" } >> "$GITHUB_STEP_SUMMARY" - name: Post notes as a comment on the release PR diff --git a/.github/workflows/skills-build-and-publish.yml b/.github/workflows/skills-build-and-publish.yml index a08b75fc5f..4c9eb20b61 100644 --- a/.github/workflows/skills-build-and-publish.yml +++ b/.github/workflows/skills-build-and-publish.yml @@ -43,6 +43,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 diff --git a/.github/workflows/test-e2e-lifecycle.yml b/.github/workflows/test-e2e-lifecycle.yml index f26cefba4b..44b0c6118c 100644 --- a/.github/workflows/test-e2e-lifecycle.yml +++ b/.github/workflows/test-e2e-lifecycle.yml @@ -102,7 +102,7 @@ jobs: # Pull and load all test server images in parallel to speed up CI echo "Pulling and loading test server images..." - docker pull ${{ env.YARDSTICK_IMAGE }} & + docker pull "$YARDSTICK_IMAGE" & docker pull ghcr.io/stackloklabs/gofetch/server:1.0.1 & docker pull ghcr.io/stackloklabs/osv-mcp/server:0.0.7 & docker pull python:3.9-slim & @@ -112,7 +112,7 @@ jobs: wait # Load all images into kind - kind load docker-image --name toolhive ${{ env.YARDSTICK_IMAGE }} + kind load docker-image --name toolhive "$YARDSTICK_IMAGE" kind load docker-image --name toolhive ghcr.io/stackloklabs/gofetch/server:1.0.1 kind load docker-image --name toolhive ghcr.io/stackloklabs/osv-mcp/server:0.0.7 kind load docker-image --name toolhive python:3.9-slim @@ -123,7 +123,7 @@ jobs: - name: Deploy operator with VMCP_IMAGE run: | export KUBECONFIG=kconfig.yaml - echo "Deploying operator with vmcp image: ${{ env.VMCP_IMAGE }}" + echo "Deploying operator with vmcp image: ${VMCP_IMAGE}" # Build operator and proxyrunner images OPERATOR_IMAGE=$(KO_DOCKER_REPO=kind.local ko build --local -B ./cmd/thv-operator | tail -n 1) @@ -137,7 +137,7 @@ jobs: helm upgrade --install toolhive-operator deploy/charts/operator \ --set operator.image=${OPERATOR_IMAGE} \ --set operator.toolhiveRunnerImage=${TOOLHIVE_IMAGE} \ - --set operator.vmcpImage=${{ env.VMCP_IMAGE }} \ + --set operator.vmcpImage=${VMCP_IMAGE} \ --namespace toolhive-system \ --create-namespace \ --kubeconfig kconfig.yaml From 7b1339b86df90fc969af870cd9d85a03b29671a5 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:06:54 +0100 Subject: [PATCH 2/2] Scope security-events and drop one more credential security-events: write sat at the workflow level in security-scan.yml, granting it to govulncheck, which never uploads SARIF. It moves to the two jobs that do. run-on-main.yml still grants the workflow the same permission when it calls it, so nothing is lost. issue-triage.yml declares contents: read, so no job in it can push and the checkout token is unusable as well as unnecessary. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/issue-triage.yml | 1 + .github/workflows/security-scan.yml | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index b1dfdf7449..db2f2ffa3f 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -17,6 +17,7 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 + persist-credentials: false - name: Run Claude Code for Issue Triage uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index fbec7dfdd6..dad0fbbd02 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -13,14 +13,18 @@ on: # Run daily at 2 AM UTC - cron: '0 2 * * *' +# security-events: write is granted per job rather than here, so only the two +# jobs that upload SARIF can write security events. permissions: contents: read - security-events: write jobs: grype-repo-scan: name: Grype Repository Scan runs-on: ubuntu-latest + permissions: + contents: read + security-events: write steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -45,6 +49,9 @@ jobs: zizmor: name: GitHub Actions Static Analysis runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # --no-exit-codes stops *findings* failing the job; this stops a tool or # network failure doing so. Both come off when the gate goes blocking. continue-on-error: true