From 20cd80a466f615b8cd81278768715481b13dc1b7 Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:54:04 -0400 Subject: [PATCH 1/4] Keep unrelated comments out of snapshot concurrency --- .github/workflows/update-visual-baselines.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/update-visual-baselines.yaml b/.github/workflows/update-visual-baselines.yaml index 3d8ce465..d9838f3f 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: From 843589ea741c136d9c83c95295641eff924a3fb7 Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:54:06 -0400 Subject: [PATCH 2/4] Verify ToolHive release signatures in Actions --- .github/workflows/_visual-regression.yaml | 5 +++ .../workflows/update-visual-baselines.yaml | 5 +++ .github/workflows/upstream-release-docs.yml | 5 +++ scripts/install-thv.sh | 37 ++++++++++++++++--- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/.github/workflows/_visual-regression.yaml b/.github/workflows/_visual-regression.yaml index 50381e1c..7b1beb65 100644 --- a/.github/workflows/_visual-regression.yaml +++ b/.github/workflows/_visual-regression.yaml @@ -72,7 +72,12 @@ jobs: exit 1 fi + - 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 d9838f3f..ea0c8c86 100644 --- a/.github/workflows/update-visual-baselines.yaml +++ b/.github/workflows/update-visual-baselines.yaml @@ -100,7 +100,12 @@ jobs: exit 1 fi + - 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 0e60bad0..d1665bde 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 diff --git a/scripts/install-thv.sh b/scripts/install-thv.sh index 9df8aa99..6ad8e0e3 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." From 48a0135f22537a9f7b1542e5db8df2a70a2c569d Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:54:07 -0400 Subject: [PATCH 3/4] Extract release PR body rendering into a script --- .github/workflows/_static-checks.yaml | 3 + .github/workflows/upstream-release-docs.yml | 315 +----------------- .../fixtures/release-1181-env.json | 28 ++ .../fixtures/release-1181-section.txt | 61 ++++ scripts/upstream-release/render-pr-body.mjs | 247 ++++++++++++++ .../upstream-release/render-pr-body.test.mjs | 30 ++ 6 files changed, 370 insertions(+), 314 deletions(-) create mode 100644 scripts/upstream-release/fixtures/release-1181-env.json create mode 100644 scripts/upstream-release/fixtures/release-1181-section.txt create mode 100644 scripts/upstream-release/render-pr-body.mjs create mode 100644 scripts/upstream-release/render-pr-body.test.mjs diff --git a/.github/workflows/_static-checks.yaml b/.github/workflows/_static-checks.yaml index 13061b8b..6fb8285e 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/upstream-release-docs.yml b/.github/workflows/upstream-release-docs.yml index d1665bde..ca260763 100644 --- a/.github/workflows/upstream-release-docs.yml +++ b/.github/workflows/upstream-release-docs.yml @@ -1267,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/upstream-release/fixtures/release-1181-env.json b/scripts/upstream-release/fixtures/release-1181-env.json new file mode 100644 index 00000000..5674274c --- /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 00000000..17de5eef --- /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 00000000..31e5aafb --- /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 00000000..c336041d --- /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}`); +}); From 5444834424616c165a9bcc76581c54dc5fc30325 Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:58:12 -0400 Subject: [PATCH 4/4] Install envsubst in Playwright workflow containers --- .github/workflows/_visual-regression.yaml | 6 ++++++ .github/workflows/update-visual-baselines.yaml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/_visual-regression.yaml b/.github/workflows/_visual-regression.yaml index 7b1beb65..98e08417 100644 --- a/.github/workflows/_visual-regression.yaml +++ b/.github/workflows/_visual-regression.yaml @@ -72,6 +72,12 @@ 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 diff --git a/.github/workflows/update-visual-baselines.yaml b/.github/workflows/update-visual-baselines.yaml index ea0c8c86..912500f7 100644 --- a/.github/workflows/update-visual-baselines.yaml +++ b/.github/workflows/update-visual-baselines.yaml @@ -100,6 +100,12 @@ 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