diff --git a/.github/workflows/_static-checks.yaml b/.github/workflows/_static-checks.yaml
index 13061b8b6..6fb8285e7 100644
--- a/.github/workflows/_static-checks.yaml
+++ b/.github/workflows/_static-checks.yaml
@@ -27,3 +27,6 @@ jobs:
- name: Test workflow helper scripts
run: python3 -m unittest discover -s .github/scripts -p 'test_*.py'
+
+ - name: Test release PR body renderer
+ run: node --test scripts/upstream-release/render-pr-body.test.mjs
diff --git a/.github/workflows/_visual-regression.yaml b/.github/workflows/_visual-regression.yaml
index 50381e1c8..98e084177 100644
--- a/.github/workflows/_visual-regression.yaml
+++ b/.github/workflows/_visual-regression.yaml
@@ -72,7 +72,18 @@ jobs:
exit 1
fi
+ # The Cosign action expands its install path with envsubst.
+ - name: Install Cosign installer dependency
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends gettext-base
+
+ - name: Install Cosign
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+
- name: Install ToolHive CLI
+ env:
+ TOOLHIVE_VERIFY_SIGNATURE: 'true'
run: ./scripts/install-thv.sh
# SWC's native loader rejects the writable parents of /github/home.
diff --git a/.github/workflows/update-visual-baselines.yaml b/.github/workflows/update-visual-baselines.yaml
index 3d8ce465c..912500f7e 100644
--- a/.github/workflows/update-visual-baselines.yaml
+++ b/.github/workflows/update-visual-baselines.yaml
@@ -8,7 +8,8 @@ on:
types: [created]
concurrency:
- group: ${{ github.workflow }}-${{ github.event.issue.number }}
+ # Non-command comments get their own group and cannot preempt a request.
+ group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ startsWith(github.event.comment.body, '/update-snapshots') && 'request' || github.run_id }}
cancel-in-progress: true
permissions:
@@ -99,7 +100,18 @@ jobs:
exit 1
fi
+ # The Cosign action expands its install path with envsubst.
+ - name: Install Cosign installer dependency
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends gettext-base
+
+ - name: Install Cosign
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+
- name: Install ToolHive CLI
+ env:
+ TOOLHIVE_VERIFY_SIGNATURE: 'true'
run: ./scripts/install-thv.sh
# SWC's native loader rejects the writable parents of /github/home.
diff --git a/.github/workflows/upstream-release-docs.yml b/.github/workflows/upstream-release-docs.yml
index 0e60bad0d..ca2607637 100644
--- a/.github/workflows/upstream-release-docs.yml
+++ b/.github/workflows/upstream-release-docs.yml
@@ -332,7 +332,12 @@ jobs:
# sets its failOnError whenever CI=true. Same script Vercel's
# buildCommand runs. Required, not soft-failed, so a broken
# install fails here rather than mid-skill.
+ - name: Install Cosign
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+
- name: Install ToolHive CLI (for build-time MCP metadata)
+ env:
+ TOOLHIVE_VERIFY_SIGNATURE: 'true'
run: |
./scripts/install-thv.sh
# The script falls back to ~/.local/bin when /usr/local/bin
@@ -1262,320 +1267,7 @@ jobs:
REVIEW_COST: ${{ steps.skill_review_stats.outputs.cost_usd }}
REFRESHED: ${{ steps.refresh.outputs.refreshed }}
run: |
- START=''
- END=''
-
- # Compose in three phases so the body reads top-to-bottom:
- # 1. Top alert (at most one, ordered by severity)
- # 2. At-a-glance table (the reviewer's first 5-second scan)
- # 3. Gaps / contributors / cost / process-lore details
- #
- # Derive some small things upfront so the table logic stays
- # readable:
-
- # Compare URL for the table's "Upstream" row.
- COMPARE_URL="https://github.com/${REPO}/compare/${PREV_TAG}...${NEW_TAG}"
-
- # Gaps count: grep for the "### " entry headings inside GAPS_BLOCK.
- if [ -n "$GAPS_BLOCK" ]; then
- GAPS_COUNT=$(printf '%s\n' "$GAPS_BLOCK" | grep -cE '^### ' || true)
- else
- GAPS_COUNT=0
- fi
-
- # Autogen-drift gate: AUTOGEN_TOUCHED is a comma-separated
- # list of paths the skill edited under auto-generated roots.
- # Non-empty means drift -- surfaced as the top-of-section
- # CAUTION alert.
- AUTOGEN_DRIFT="$AUTOGEN_TOUCHED"
-
- # Silent-run check: both skill steps ran OK and produced 0
- # commits. NOTE_BLOCK covers the NO_CHANGES.md path; we only
- # emit our own silent-run note when NOTE_BLOCK is empty.
- SILENT_RUN=false
- if [ "$SKILL_COMMIT_COUNT" = "0" ] \
- && [ -z "$NOTE_BLOCK" ] \
- && [ "$GEN_CONCLUSION" = "success" ] \
- && [ "$REVIEW_CONCLUSION" = "success" ]; then
- SILENT_RUN=true
- fi
-
- # Action-required verdict drives the At-a-glance table's
- # last row. Priority: autogen drift > gaps > unresolved review
- # routing > silent run > content review > none.
- if [ -n "$AUTOGEN_DRIFT" ]; then
- ACTION_REQUIRED="**Yes** — revert auto-generated-path drift (see above)"
- elif [ "$GAPS_COUNT" -gt 0 ]; then
- ACTION_REQUIRED="**Yes** — resolve $GAPS_COUNT gap(s), then spot-check prose"
- elif [ "${UNRESOLVED_COUNT:-0}" -gt 0 ]; then
- ACTION_REQUIRED="**Yes** — manually route $UNRESOLVED_COUNT unresolved review(s)"
- elif [ "$SILENT_RUN" = "true" ]; then
- ACTION_REQUIRED="**None** — approve and merge if the silent-run signal is expected"
- elif [ "$SKILL_COMMIT_COUNT" != "0" ] && [ -n "$SKILL_COMMIT_COUNT" ]; then
- ACTION_REQUIRED="Spot-check skill-authored prose for accuracy"
- else
- ACTION_REQUIRED="—"
- fi
-
- # Hand-written-changes cell: either a commit count, or a
- # placeholder when the skill step didn't run.
- if [ -n "$SKILL_COMMIT_COUNT" ]; then
- CHANGES_CELL="$SKILL_COMMIT_COUNT commit(s)"
- else
- CHANGES_CELL="—"
- fi
-
- # Reference-assets cell.
- case "$REFRESHED" in
- true) REFRESH_CELL="refreshed (separate commit)" ;;
- false) REFRESH_CELL="unchanged" ;;
- *) REFRESH_CELL="—" ;;
- esac
-
- # Contributor counts. Requested reviewers already appear in
- # GitHub's reviewer sidebar, so we don't also list them in
- # the PR body -- that would duplicate info across three
- # places (sidebar, at-a-glance cell, dedicated section).
- if [ -n "$ASSIGN_LIST" ]; then
- ASSIGN_COUNT=$(echo "$ASSIGN_LIST" | tr ',' '\n' | grep -c . || true)
- else
- ASSIGN_COUNT=0
- fi
-
- # COMPARE_OK tri-state:
- # "true" -> compare succeeded, contributor list is populated
- # "false" -> compare was attempted and failed (pinned prev_tag
- # missing upstream)
- # "" -> reviewers step never ran (earlier step in the job
- # failed and short-circuited before we got there).
- # The augment step itself is `if: always()`, so it
- # still renders the PR body -- but the contributor
- # cell must say "not attempted" rather than claim
- # a compare failure that never happened.
- if [ -z "$COMPARE_OK" ]; then
- CONTRIB_CELL="**Not attempted** — run failed before reviewer assignment"
- elif [ "$COMPARE_OK" != "true" ]; then
- CONTRIB_CELL="**Compare failed** — pinned \`$PREV_TAG\` missing upstream, no auto-assignment"
- elif [ "$ASSIGN_COUNT" -gt 0 ]; then
- CONTRIB_CELL="$ASSIGN_COUNT review requested (see sidebar)"
- else
- CONTRIB_CELL="none in release range"
- fi
-
- # Fold the count into the same cell so the table keeps one
- # contributor row. These contributors have no review action,
- # so the workflow does not need to determine membership.
- FYI_COUNT=${FYI_COUNT:-0}
- if [ "$FYI_COUNT" -gt 0 ]; then
- CONTRIB_CELL="$CONTRIB_CELL · $FYI_COUNT not requested (no docs impact)"
- fi
- UNRESOLVED_COUNT=${UNRESOLVED_COUNT:-0}
- if [ "$UNRESOLVED_COUNT" -gt 0 ]; then
- CONTRIB_CELL="$CONTRIB_CELL · **$UNRESOLVED_COUNT review routing issue(s)**"
- fi
-
- # Owner cell. An unresolved owner means nobody is on the
- # hook for merging, so it renders bold rather than blank.
- if [ -n "$OWNER" ]; then
- OWNER_CELL="@$OWNER"
- if [ "$OWNER_ASSIGNED" != "true" ]; then
- OWNER_CELL="$OWNER_CELL (couldn't set as assignee)"
- fi
- if [ -n "$OWNER_SOURCE" ]; then
- OWNER_CELL="$OWNER_CELL - identified from $OWNER_SOURCE"
- fi
- else
- OWNER_CELL="**Unresolved** - no human found for this release; a docs maintainer needs to adopt this PR"
- fi
-
- {
- echo "$START"
- echo ""
- echo "## Docs update for \`$PROJECT_ID\` $NEW_TAG"
- echo ""
-
- # ----- TOP ALERT (at most one, by severity) -----
- if [ -n "$AUTOGEN_DRIFT" ]; then
- echo "> [!CAUTION]"
- echo "> **Auto-generated-path drift**: the skill edited files that should"
- echo "> only come from the refresh step. Review and revert:"
- echo ">"
- printf '%s\n' "$AUTOGEN_DRIFT" | tr ',' '\n' | awk '{ print "> - \x60" $0 "\x60" }'
- echo ""
- elif [ -n "$NOTE_BLOCK" ]; then
- # NO_CHANGES.md path -- the skill explicitly said no doc-
- # relevant changes. Simpler than the silent-run case.
- echo "> [!NOTE]"
- echo "> Skill reported **no doc-relevant changes** for this release."
- echo "> This PR only bumps the version pin and any pin_files edits."
- echo ""
- elif [ "$SILENT_RUN" = "true" ]; then
- echo "> [!NOTE]"
- echo "> **Silent run** — skill produced no content commits. The docs are"
- echo "> likely already up-to-date (e.g. \`main\` ahead of pin, or a re-run"
- echo "> after a previous PR for this tag merged). Only the version bump"
- echo "> and refreshed reference assets are included."
- echo ""
- fi
-
- # ----- AT-A-GLANCE TABLE -----
- echo "### At a glance"
- echo ""
- echo "| | |"
- echo "| --- | --- |"
- echo "| **Upstream** | \`$REPO\` [\`$PREV_TAG\` → \`$NEW_TAG\`]($COMPARE_URL) |"
- echo "| **Hand-written changes** | $CHANGES_CELL |"
- echo "| **Reference assets** | $REFRESH_CELL |"
- echo "| **Gaps** | $GAPS_COUNT |"
- echo "| **Owner** | $OWNER_CELL |"
- echo "| **Release contributors** | $CONTRIB_CELL |"
- echo "| **Action required** | $ACTION_REQUIRED |"
- echo ""
-
- # ----- OWNERSHIP -----
- # Directly under the table and kept to three lines, so
- # "who merges this" is answered before anything a reader
- # might skim past.
- echo "### Who does what"
- echo ""
- if [ -n "$OWNER" ]; then
- echo "@$OWNER cut this release and owns this PR: review your own changes, chase the remaining approvals, and merge once they're in. You don't need to wait on a review from anyone listed as having no docs impact below."
- else
- echo "No release owner could be resolved automatically, so this PR has no assignee. A docs maintainer needs to adopt it, collect approvals, and merge."
- fi
- echo ""
- echo "Everyone with a review request: the target is a review and approval within **2 business days**."
- echo ""
-
- # ----- SUMMARY OF CHANGES -----
- # Renders either the skill's hand-written SUMMARY.md body
- # or the commit-title fallback from the signals step.
- # Skipped entirely on silent / NO_CHANGES runs where the
- # signals step leaves SUMMARY_BLOCK empty.
- if [ -n "$SUMMARY_BLOCK" ]; then
- echo "### Summary of changes"
- echo ""
- echo "$SUMMARY_BLOCK"
- echo ""
- fi
-
- # ----- GAPS (when present) -----
- if [ -n "$GAPS_BLOCK" ]; then
- # Skill's GAPS.md starts at H2 ("## Gaps needing human
- # context") with per-entry H3s inside. To nest correctly
- # under our H2 ("Docs update for …"), demote each
- # heading one level (## -> ###, ### -> ####), except the
- # section heading itself stays at ### so the entries
- # still appear as distinct subsections.
- awk '
- /^## Gaps needing human context$/ { print "### Gaps needing human context"; next }
- /^### / { sub(/^### /, "#### "); print; next }
- /^## / { sub(/^## /, "### "); print; next }
- { print }
- ' <<<"$GAPS_BLOCK"
- echo ""
- fi
-
- # ----- NO-DOCS-IMPACT CONTRIBUTORS (no review requested) -----
- # Contributors in this group get no automatic mention. They
- # have no review action, so membership is irrelevant.
- if [ "$FYI_COUNT" -gt 0 ]; then
- echo "### No docs impact identified"
- echo ""
- echo "$FYI_COUNT contributor(s) had no-docs-impact commits in this release. No review was requested, and the workflow did not auto-notify them."
- echo ""
- fi
-
- # ----- REVIEW ROUTING -----
- # Explain stand-in requests and surface anything that
- # requires manual routing. Contributors who cannot review this
- # repository are not @-mentioned.
- STANDIN_COUNT=0
- if [ -n "$STANDIN_BLOCK" ]; then
- STANDIN_COUNT=$(printf '%s\n' "$STANDIN_BLOCK" | grep -c . || true)
- fi
- if [ "$STANDIN_COUNT" -gt 0 ] || [ "$UNRESOLVED_COUNT" -gt 0 ]; then
- echo "### Review routing"
- echo ""
- if [ "$STANDIN_COUNT" -gt 0 ]; then
- echo "GitHub could not request the upstream contributor directly, so review went to the human merger of each relevant upstream PR:"
- echo ""
- printf '%s\n' "$STANDIN_BLOCK" | awk '{ print "- " $0 }'
- echo ""
- fi
- if [ "$UNRESOLVED_COUNT" -gt 0 ]; then
- echo "These reviews require manual routing:"
- echo ""
- printf '%s\n' "$UNRESOLVED_BLOCK" | awk '{ print "- " $0 }'
- echo ""
- fi
- fi
-
- # ----- RUN COST -----
- if [ -n "$GEN_TURNS" ] || [ -n "$REVIEW_TURNS" ]; then
- echo "### Run cost"
- echo ""
- echo "| Session | Turns | Cost (USD) |"
- echo "| --- | ---: | ---: |"
- if [ -n "$GEN_TURNS" ]; then
- echo "| Generation | $GEN_TURNS | \$$GEN_COST |"
- fi
- if [ -n "$REVIEW_TURNS" ]; then
- echo "| Editorial review | $REVIEW_TURNS | \$$REVIEW_COST |"
- fi
- if [ -n "$GEN_TURNS" ] && [ -n "$REVIEW_TURNS" ]; then
- TOTAL_TURNS=$((GEN_TURNS + REVIEW_TURNS))
- TOTAL_COST=$(awk -v a="$GEN_COST" -v b="$REVIEW_COST" 'BEGIN { printf "%.4f", a + b }')
- echo "| **Total** | **$TOTAL_TURNS** | **\$$TOTAL_COST** |"
- fi
- echo ""
- fi
-
- # ----- PROCESS DETAILS (collapsed) -----
- echo "How this PR was built
"
- echo ""
- echo "Two Claude Opus sessions run per release: a generation pass"
- echo "(\`upstream-release-docs\` skill, 6 phases) followed by a fresh-"
- echo "context editorial pass (\`docs-review\`). Prettier/ESLint"
- echo "auto-fixes are applied after."
- echo ""
- echo "Auto-synced paths — do not hand-edit these in review:"
- echo "- \`static/api-specs/\`"
- echo "- \`docs/toolhive/reference/cli/\` (toolhive only)"
- # Emit one bullet per CRD pages dir declared in the project config.
- for dir in $CRD_PAGES_DIRS; do
- echo "- \`${dir}/\`"
- done
- echo ""
- echo "If a \"Gaps needing human context\" section is present above,"
- echo "each entry includes a paste-ready **Helper prompt for local"
- echo "Claude** a reviewer can use to resolve the gap."
- echo ""
- echo " "
- echo ""
-
- echo "$END"
- } > /tmp/section.md
-
- # Read existing body, replace or append our marked section.
- EXISTING=$(gh pr view "$PR_NUMBER" --json body --jq .body)
- if echo "$EXISTING" | grep -qF "$START"; then
- # Replace existing section.
- printf '%s\n' "$EXISTING" | awk -v start="$START" -v end="$END" -v repl_file=/tmp/section.md '
- BEGIN { in_section = 0 }
- $0 == start { in_section = 1; while ((getline line < repl_file) > 0) print line; next }
- $0 == end { if (in_section) { in_section = 0; next } }
- !in_section { print }
- ' > /tmp/pr-body.md
- else
- # Append.
- {
- printf '%s\n\n---\n\n' "$EXISTING"
- cat /tmp/section.md
- } > /tmp/pr-body.md
- fi
-
- gh pr edit "$PR_NUMBER" --body-file /tmp/pr-body.md
+ node scripts/upstream-release/render-pr-body.mjs
# Counterpart to the "Convert PR to draft" step at the top of
# the job. Uses success() (not always()) on purpose: a failed
diff --git a/scripts/install-thv.sh b/scripts/install-thv.sh
index 9df8aa995..6ad8e0e38 100755
--- a/scripts/install-thv.sh
+++ b/scripts/install-thv.sh
@@ -52,7 +52,10 @@ else
for profile in ~/.bashrc ~/.zshrc ~/.profile; do
if [[ -f "$profile" ]]; then
# Check if the PATH export already exists to avoid duplicates
+ # Keep the variables literal for the profile to expand at login.
+ # shellcheck disable=SC2016
if ! grep -q 'export PATH="$HOME/.local/bin:$PATH"' "$profile" 2>/dev/null; then
+ # shellcheck disable=SC2016
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$profile"
echo "Added ~/.local/bin to PATH in $profile"
fi
@@ -63,13 +66,35 @@ else
export PATH="$HOME/.local/bin:$PATH"
fi
-# Download the release tarball and extract the binary
+# Keep downloads isolated, and verify before extracting or executing the CLI.
+DOWNLOAD_DIR=$(mktemp -d)
+trap 'rm -rf "$DOWNLOAD_DIR"' EXIT
+
echo "Downloading ToolHive CLI release $RELEASE_VERSION"
-curl -s -L "$RELEASE_TARBALL" -o /tmp/toolhive.tar.gz
-tar -xzf /tmp/toolhive.tar.gz -C /tmp thv
-chmod +x /tmp/thv
-cp /tmp/thv "$INSTALL_DIR/thv"
-rm -f /tmp/toolhive.tar.gz /tmp/thv
+curl -fsSL "$RELEASE_TARBALL" -o "$DOWNLOAD_DIR/toolhive.tar.gz"
+
+if [[ "${TOOLHIVE_VERIFY_SIGNATURE:-false}" == "true" ]]; then
+ command -v cosign >/dev/null 2>&1 || {
+ echo "Error: cosign is required for signature verification"
+ exit 1
+ }
+ BUNDLE_URL=$(echo "$RELEASE_JSON" | jq -r \
+ --arg version "$RELEASE_VERSION" \
+ '.assets[] | select(.name == "toolhive_" + $version + "_linux_amd64.tar.gz.sigstore.json") | .browser_download_url // empty')
+ if [[ -z "$BUNDLE_URL" ]]; then
+ echo "Error: signature bundle missing for release $RELEASE_VERSION"
+ exit 1
+ fi
+ curl -fsSL "$BUNDLE_URL" -o "$DOWNLOAD_DIR/toolhive.sigstore.json"
+ cosign verify-blob "$DOWNLOAD_DIR/toolhive.tar.gz" \
+ --bundle "$DOWNLOAD_DIR/toolhive.sigstore.json" \
+ --certificate-identity "https://github.com/stacklok/toolhive/.github/workflows/releaser.yml@refs/tags/v$RELEASE_VERSION" \
+ --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
+fi
+
+tar -xzf "$DOWNLOAD_DIR/toolhive.tar.gz" -C "$DOWNLOAD_DIR" thv
+chmod +x "$DOWNLOAD_DIR/thv"
+cp "$DOWNLOAD_DIR/thv" "$INSTALL_DIR/thv"
thv version || {
echo "Installation failed: 'thv' command is not working."
diff --git a/scripts/upstream-release/fixtures/release-1181-env.json b/scripts/upstream-release/fixtures/release-1181-env.json
new file mode 100644
index 000000000..5674274c2
--- /dev/null
+++ b/scripts/upstream-release/fixtures/release-1181-env.json
@@ -0,0 +1,28 @@
+{
+ "PROJECT_ID": "toolhive",
+ "NEW_TAG": "v0.51.4",
+ "PREV_TAG": "v0.51.1",
+ "REPO": "stacklok/toolhive",
+ "NOTE_BLOCK": "",
+ "GAPS_BLOCK": "",
+ "SUMMARY_BLOCK": "- Added `thv secret upgrade-protection` guidance in `docs/toolhive/guides-cli/secrets-management.mdx`, explaining when to migrate legacy encrypted stores to Argon2id and the one-way compatibility break.\n- Updated `docs/toolhive/guides-vmcp/tool-aggregation.mdx` to note that the `priority` strategy now drops every candidate in a tool-name collision when any conflicting backend is absent from `priorityOrder` (verified against `pkg/vmcp/aggregator/priority_resolver.go`).\n- Updated `docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx` and `docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx` to require each upstream provider's `redirectUri` to share the browser-facing authorize hostname (`issuer` or `authorizationEndpointBaseUrl`), because `/oauth/callback` binds each login to the browser that started it with a host-only cookie.",
+ "AUTOGEN_TOUCHED": "",
+ "CRD_PAGES_DIRS": "docs/toolhive/reference/crds",
+ "COMPARE_OK": "true",
+ "STANDIN_BLOCK": "@JAORMX (merged stacklok/toolhive#6127)",
+ "UNRESOLVED_BLOCK": "",
+ "UNRESOLVED_COUNT": "0",
+ "ASSIGN_LIST": "JAORMX,jhrozek,rdimitrov",
+ "FYI_COUNT": "0",
+ "OWNER": "JAORMX",
+ "OWNER_ASSIGNED": "true",
+ "OWNER_SOURCE": "merged release PR stacklok/toolhive#6728",
+ "SKILL_COMMIT_COUNT": "2",
+ "GEN_CONCLUSION": "success",
+ "REVIEW_CONCLUSION": "success",
+ "GEN_TURNS": "93",
+ "GEN_COST": "11.5335",
+ "REVIEW_TURNS": "36",
+ "REVIEW_COST": "2.8230",
+ "REFRESHED": "true"
+}
diff --git a/scripts/upstream-release/fixtures/release-1181-section.txt b/scripts/upstream-release/fixtures/release-1181-section.txt
new file mode 100644
index 000000000..17de5eef0
--- /dev/null
+++ b/scripts/upstream-release/fixtures/release-1181-section.txt
@@ -0,0 +1,61 @@
+
+
+## Docs update for `toolhive` v0.51.4
+
+### At a glance
+
+| | |
+| --- | --- |
+| **Upstream** | `stacklok/toolhive` [`v0.51.1` → `v0.51.4`](https://github.com/stacklok/toolhive/compare/v0.51.1...v0.51.4) |
+| **Hand-written changes** | 2 commit(s) |
+| **Reference assets** | refreshed (separate commit) |
+| **Gaps** | 0 |
+| **Owner** | @JAORMX - identified from merged release PR stacklok/toolhive#6728 |
+| **Release contributors** | 3 review requested (see sidebar) |
+| **Action required** | Spot-check skill-authored prose for accuracy |
+
+### Who does what
+
+@JAORMX cut this release and owns this PR: review your own changes, chase the remaining approvals, and merge once they're in. You don't need to wait on a review from anyone listed as having no docs impact below.
+
+Everyone with a review request: the target is a review and approval within **2 business days**.
+
+### Summary of changes
+
+- Added `thv secret upgrade-protection` guidance in `docs/toolhive/guides-cli/secrets-management.mdx`, explaining when to migrate legacy encrypted stores to Argon2id and the one-way compatibility break.
+- Updated `docs/toolhive/guides-vmcp/tool-aggregation.mdx` to note that the `priority` strategy now drops every candidate in a tool-name collision when any conflicting backend is absent from `priorityOrder` (verified against `pkg/vmcp/aggregator/priority_resolver.go`).
+- Updated `docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx` and `docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx` to require each upstream provider's `redirectUri` to share the browser-facing authorize hostname (`issuer` or `authorizationEndpointBaseUrl`), because `/oauth/callback` binds each login to the browser that started it with a host-only cookie.
+
+### Review routing
+
+GitHub could not request the upstream contributor directly, so review went to the human merger of each relevant upstream PR:
+
+- @JAORMX (merged stacklok/toolhive#6127)
+
+### Run cost
+
+| Session | Turns | Cost (USD) |
+| --- | ---: | ---: |
+| Generation | 93 | $11.5335 |
+| Editorial review | 36 | $2.8230 |
+| **Total** | **129** | **$14.3565** |
+
+How this PR was built
+
+Two Claude Opus sessions run per release: a generation pass
+(`upstream-release-docs` skill, 6 phases) followed by a fresh-
+context editorial pass (`docs-review`). Prettier/ESLint
+auto-fixes are applied after.
+
+Auto-synced paths — do not hand-edit these in review:
+- `static/api-specs/`
+- `docs/toolhive/reference/cli/` (toolhive only)
+- `docs/toolhive/reference/crds/`
+
+If a "Gaps needing human context" section is present above,
+each entry includes a paste-ready **Helper prompt for local
+Claude** a reviewer can use to resolve the gap.
+
+
+
+
diff --git a/scripts/upstream-release/render-pr-body.mjs b/scripts/upstream-release/render-pr-body.mjs
new file mode 100644
index 000000000..31e5aafbc
--- /dev/null
+++ b/scripts/upstream-release/render-pr-body.mjs
@@ -0,0 +1,247 @@
+#!/usr/bin/env node
+// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// Render the release workflow's marked section without contacting GitHub.
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+const START = '';
+const END = '';
+const countLines = (value) => value.split('\n').filter(Boolean).length;
+
+export function renderSection(env) {
+ const v = new Proxy(
+ { ...env, START, END },
+ { get: (target, key) => target[key] ?? '' }
+ );
+ v.COMPARE_URL = `https://github.com/${v.REPO}/compare/${v.PREV_TAG}...${v.NEW_TAG}`;
+ v.GAPS_COUNT = v.GAPS_BLOCK.split('\n').filter((line) =>
+ /^### /.test(line)
+ ).length;
+ v.AUTOGEN_DRIFT = v.AUTOGEN_TOUCHED;
+ const silent =
+ v.SKILL_COMMIT_COUNT === '0' &&
+ !v.NOTE_BLOCK &&
+ v.GEN_CONCLUSION === 'success' &&
+ v.REVIEW_CONCLUSION === 'success';
+ v.UNRESOLVED_COUNT ||= '0';
+ v.FYI_COUNT ||= '0';
+ v.ACTION_REQUIRED = v.AUTOGEN_DRIFT
+ ? '**Yes** — revert auto-generated-path drift (see above)'
+ : v.GAPS_COUNT > 0
+ ? `**Yes** — resolve ${v.GAPS_COUNT} gap(s), then spot-check prose`
+ : Number(v.UNRESOLVED_COUNT) > 0
+ ? `**Yes** — manually route ${v.UNRESOLVED_COUNT} unresolved review(s)`
+ : silent
+ ? '**None** — approve and merge if the silent-run signal is expected'
+ : v.SKILL_COMMIT_COUNT && v.SKILL_COMMIT_COUNT !== '0'
+ ? 'Spot-check skill-authored prose for accuracy'
+ : '—';
+ v.CHANGES_CELL = v.SKILL_COMMIT_COUNT
+ ? `${v.SKILL_COMMIT_COUNT} commit(s)`
+ : '—';
+ v.REFRESH_CELL =
+ { true: 'refreshed (separate commit)', false: 'unchanged' }[v.REFRESHED] ??
+ '—';
+ const assigned = countLines(v.ASSIGN_LIST.replaceAll(',', '\n'));
+ v.CONTRIB_CELL = !v.COMPARE_OK
+ ? '**Not attempted** — run failed before reviewer assignment'
+ : v.COMPARE_OK !== 'true'
+ ? `**Compare failed** — pinned \`${v.PREV_TAG}\` missing upstream, no auto-assignment`
+ : assigned > 0
+ ? `${assigned} review requested (see sidebar)`
+ : 'none in release range';
+ if (Number(v.FYI_COUNT) > 0)
+ v.CONTRIB_CELL += ` · ${v.FYI_COUNT} not requested (no docs impact)`;
+ if (Number(v.UNRESOLVED_COUNT) > 0)
+ v.CONTRIB_CELL += ` · **${v.UNRESOLVED_COUNT} review routing issue(s)**`;
+ v.OWNER_CELL = v.OWNER
+ ? `@${v.OWNER}`
+ : '**Unresolved** - no human found for this release; a docs maintainer needs to adopt this PR';
+ if (v.OWNER && v.OWNER_ASSIGNED !== 'true')
+ v.OWNER_CELL += " (couldn't set as assignee)";
+ if (v.OWNER && v.OWNER_SOURCE)
+ v.OWNER_CELL += ` - identified from ${v.OWNER_SOURCE}`;
+ const lines = [];
+ const emit = (line = '') => lines.push(line);
+ emit(`${v.START}`);
+ emit(``);
+ emit(`## Docs update for \`${v.PROJECT_ID}\` ${v.NEW_TAG}`);
+ emit(``);
+ if (v.AUTOGEN_DRIFT) {
+ emit(`> [!CAUTION]`);
+ emit(`> **Auto-generated-path drift**: the skill edited files that should`);
+ emit(`> only come from the refresh step. Review and revert:`);
+ emit(`>`);
+ for (const entry of v.AUTOGEN_DRIFT.split(',')) emit(`> - \`${entry}\``);
+ emit();
+ } else if (v.NOTE_BLOCK) {
+ emit(`> [!NOTE]`);
+ emit(`> Skill reported **no doc-relevant changes** for this release.`);
+ emit(`> This PR only bumps the version pin and any pin_files edits.`);
+ emit(``);
+ } else if (silent) {
+ emit(`> [!NOTE]`);
+ emit(`> **Silent run** — skill produced no content commits. The docs are`);
+ emit(
+ `> likely already up-to-date (e.g. \`main\` ahead of pin, or a re-run`
+ );
+ emit(`> after a previous PR for this tag merged). Only the version bump`);
+ emit(`> and refreshed reference assets are included.`);
+ emit(``);
+ }
+ emit(`### At a glance`);
+ emit(``);
+ emit(`| | |`);
+ emit(`| --- | --- |`);
+ emit(
+ `| **Upstream** | \`${v.REPO}\` [\`${v.PREV_TAG}\` → \`${v.NEW_TAG}\`](${v.COMPARE_URL}) |`
+ );
+ emit(`| **Hand-written changes** | ${v.CHANGES_CELL} |`);
+ emit(`| **Reference assets** | ${v.REFRESH_CELL} |`);
+ emit(`| **Gaps** | ${v.GAPS_COUNT} |`);
+ emit(`| **Owner** | ${v.OWNER_CELL} |`);
+ emit(`| **Release contributors** | ${v.CONTRIB_CELL} |`);
+ emit(`| **Action required** | ${v.ACTION_REQUIRED} |`);
+ emit(``);
+ emit(`### Who does what`);
+ emit(``);
+ if (v.OWNER) {
+ emit(
+ `@${v.OWNER} cut this release and owns this PR: review your own changes, chase the remaining approvals, and merge once they're in. You don't need to wait on a review from anyone listed as having no docs impact below.`
+ );
+ } else {
+ emit(
+ `No release owner could be resolved automatically, so this PR has no assignee. A docs maintainer needs to adopt it, collect approvals, and merge.`
+ );
+ }
+ emit(``);
+ emit(
+ `Everyone with a review request: the target is a review and approval within **2 business days**.`
+ );
+ emit(``);
+ if (v.SUMMARY_BLOCK) {
+ emit(`### Summary of changes`);
+ emit(``);
+ emit(`${v.SUMMARY_BLOCK}`);
+ emit(``);
+ }
+ if (v.GAPS_BLOCK) {
+ emit(v.GAPS_BLOCK.replace(/^### /gm, '#### ').replace(/^## /gm, '### '));
+ emit();
+ }
+ if (Number(v.FYI_COUNT) > 0) {
+ emit(`### No docs impact identified`);
+ emit(``);
+ emit(
+ `${v.FYI_COUNT} contributor(s) had no-docs-impact commits in this release. No review was requested, and the workflow did not auto-notify them.`
+ );
+ emit(``);
+ }
+ const standins = countLines(v.STANDIN_BLOCK);
+ if (standins > 0 || Number(v.UNRESOLVED_COUNT) > 0) {
+ emit(`### Review routing`);
+ emit(``);
+ if (standins > 0) {
+ emit(
+ `GitHub could not request the upstream contributor directly, so review went to the human merger of each relevant upstream PR:`
+ );
+ emit(``);
+ for (const line of v.STANDIN_BLOCK.split('\n')) emit(`- ${line}`);
+ emit();
+ }
+ if (Number(v.UNRESOLVED_COUNT) > 0) {
+ emit(`These reviews require manual routing:`);
+ emit(``);
+ for (const line of v.UNRESOLVED_BLOCK.split('\n')) emit(`- ${line}`);
+ emit();
+ }
+ }
+ if (v.GEN_TURNS || v.REVIEW_TURNS) {
+ emit(`### Run cost`);
+ emit(``);
+ emit(`| Session | Turns | Cost (USD) |`);
+ emit(`| --- | ---: | ---: |`);
+ if (v.GEN_TURNS) {
+ emit(`| Generation | ${v.GEN_TURNS} | $${v.GEN_COST} |`);
+ }
+ if (v.REVIEW_TURNS) {
+ emit(`| Editorial review | ${v.REVIEW_TURNS} | $${v.REVIEW_COST} |`);
+ }
+ if (v.GEN_TURNS && v.REVIEW_TURNS) {
+ v.TOTAL_TURNS = Number(v.GEN_TURNS) + Number(v.REVIEW_TURNS);
+ v.TOTAL_COST = (Number(v.GEN_COST) + Number(v.REVIEW_COST)).toFixed(4);
+ emit(`| **Total** | **${v.TOTAL_TURNS}** | **$${v.TOTAL_COST}** |`);
+ }
+ emit();
+ }
+ emit(`How this PR was built
`);
+ emit(``);
+ emit(`Two Claude Opus sessions run per release: a generation pass`);
+ emit(`(\`upstream-release-docs\` skill, 6 phases) followed by a fresh-`);
+ emit(`context editorial pass (\`docs-review\`). Prettier/ESLint`);
+ emit(`auto-fixes are applied after.`);
+ emit(``);
+ emit(`Auto-synced paths — do not hand-edit these in review:`);
+ emit(`- \`static/api-specs/\``);
+ emit(`- \`docs/toolhive/reference/cli/\` (toolhive only)`);
+ for (const dir of v.CRD_PAGES_DIRS.trim().split(/\s+/).filter(Boolean))
+ emit(`- \`${dir}/\``);
+ emit(``);
+ emit(`If a "Gaps needing human context" section is present above,`);
+ emit(`each entry includes a paste-ready **Helper prompt for local`);
+ emit(`Claude** a reviewer can use to resolve the gap.`);
+ emit(``);
+ emit(` `);
+ emit(``);
+ emit(`${v.END}`);
+ return lines.join('\n') + '\n';
+}
+
+export function renderBody(existing, env) {
+ const section = renderSection(env);
+ // Shell command substitution stripped trailing newlines from the old body.
+ const body = existing.replace(/\n+$/, '');
+ if (!body.includes(START)) return `${body}\n\n---\n\n${section}`;
+ let inside = false;
+ const lines = [];
+ for (const line of body.split('\n')) {
+ if (line === START) {
+ inside = true;
+ lines.push(section.replace(/\n$/, ''));
+ } else if (line === END && inside) {
+ inside = false;
+ } else if (!inside) {
+ lines.push(line);
+ }
+ }
+ return lines.join('\n') + '\n';
+}
+
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ const pr = process.env.PR_NUMBER;
+ if (!/^[1-9][0-9]*$/.test(pr ?? ''))
+ throw new Error('PR_NUMBER must be a positive integer');
+ const existing = execFileSync(
+ 'gh',
+ ['pr', 'view', pr, '--json', 'body', '--jq', '.body'],
+ { encoding: 'utf8' }
+ );
+ const directory = mkdtempSync(join(tmpdir(), 'release-pr-body-'));
+ try {
+ const file = join(directory, 'body.md');
+ writeFileSync(file, renderBody(existing, process.env));
+ execFileSync('gh', ['pr', 'edit', pr, '--body-file', file], {
+ stdio: 'inherit',
+ });
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
diff --git a/scripts/upstream-release/render-pr-body.test.mjs b/scripts/upstream-release/render-pr-body.test.mjs
new file mode 100644
index 000000000..c336041d9
--- /dev/null
+++ b/scripts/upstream-release/render-pr-body.test.mjs
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import { renderBody, renderSection } from './render-pr-body.mjs';
+
+// Captured from run 36681404229 and the actual body of release PR #1181.
+const env = JSON.parse(
+ readFileSync(
+ new URL('./fixtures/release-1181-env.json', import.meta.url),
+ 'utf8'
+ )
+);
+const expected = readFileSync(
+ new URL('./fixtures/release-1181-section.txt', import.meta.url),
+ 'utf8'
+);
+
+test('matches the historical release PR section byte-for-byte', () => {
+ assert.equal(renderSection(env), expected);
+});
+
+test('preserves surrounding content and replaces the section idempotently', () => {
+ const body = `Intro\n\n${expected}\nOutro\n`;
+ assert.equal(renderBody(body, env), body);
+ assert.equal(renderBody(renderBody(body, env), env), body);
+ assert.equal(renderBody('Intro\n', env), `Intro\n\n---\n\n${expected}`);
+});