From ff22aabfc4ecdb8f41158bcf2299304baf6ea4cf Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 14 Sep 2026 14:27:09 -0700 Subject: [PATCH 1/6] Block pre-release CLI versions from triggering docs updates trigger-docs.yml fired on release `published`, which GitHub sends for pre-releases too. v1.9.0-rc.1 (run #38) and v1.9.0 published as a pre-release (run #39) both dispatched update-cli-docs.yml in temporalio/documentation; run #38 opened an unwanted docs PR. Switch to `released`, which fires on a direct full-release publish and on a pre-release-to-release promotion, but never for a pre-release. Add a job-level gate that also rejects drafts and side-channel tags (v1.8.3-server-1.32.0-162.0, v1.7.4-standalone-nexus-operations, v1.6.3-serverless, and similar) built from feature branches, as defense in depth behind the event-type change. workflow_dispatch stays exempt so docs can still be backfilled on demand. --- .github/workflows/trigger-docs.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/trigger-docs.yml b/.github/workflows/trigger-docs.yml index a6124050c..ba0db7d71 100644 --- a/.github/workflows/trigger-docs.yml +++ b/.github/workflows/trigger-docs.yml @@ -2,14 +2,33 @@ name: Trigger CLI docs update on: workflow_dispatch: release: - types: [published] + # `released` fires when a release is published as a full release, and when an + # existing pre-release is promoted to a full release. It never fires for + # pre-releases. `published` is deliberately not listed: it fires for + # pre-releases too, and listing both would double-fire on a full release. + types: [released] permissions: contents: read jobs: update: - if: github.repository == 'temporalio/cli' + # Belt-and-braces guard behind `types: [released]`: + # - prerelease/draft: rejects a pre-release even if `released` ever fires for one. + # - tag shape: rejects side-channel tags built from feature branches + # (v1.8.3-server-1.32.0-162.0, v1.7.4-standalone-nexus-operations, + # v1.6.3-serverless), which are published from non-mainline code and must + # not regenerate published docs. + # workflow_dispatch is exempt so docs can be backfilled for any tag on demand; + # the tag it passes is validated by the receiving workflow in + # temporalio/documentation. + if: | + github.repository == 'temporalio/cli' + && (github.event_name == 'workflow_dispatch' + || (github.event.release.prerelease == false + && github.event.release.draft == false + && startsWith(github.event.release.tag_name, 'v') + && !contains(github.event.release.tag_name, '-'))) runs-on: ubuntu-latest defaults: run: From d38acdf07de1efa4eb16c0c973679433ad4b1f24 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 14 Sep 2026 14:27:49 -0700 Subject: [PATCH 2/6] Source the release tag from the release payload, not github.ref_name github.ref_name resolves to the selected branch on a manual dispatch, not a tag: run #37 dispatched cli_release_tag=main to temporalio/documentation. Add a required release_tag input for workflow_dispatch, and source RELEASE_TAG from the release payload's tag_name on the release event or from the new input on dispatch. --- .github/workflows/trigger-docs.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/trigger-docs.yml b/.github/workflows/trigger-docs.yml index ba0db7d71..3f6971585 100644 --- a/.github/workflows/trigger-docs.yml +++ b/.github/workflows/trigger-docs.yml @@ -1,6 +1,11 @@ name: Trigger CLI docs update on: workflow_dispatch: + inputs: + release_tag: + description: "CLI release tag to generate docs from (for example, v1.9.0)." + required: true + type: string release: # `released` fires when a release is published as a full release, and when an # existing pre-release is promoted to a full release. It never fires for @@ -30,6 +35,11 @@ jobs: && startsWith(github.event.release.tag_name, 'v') && !contains(github.event.release.tag_name, '-'))) runs-on: ubuntu-latest + env: + # Read the tag from the release payload rather than github.ref_name, which + # resolves to the selected branch on a manual run (run #37 dispatched + # cli_release_tag=main to temporalio/documentation as a result). + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.event.release.tag_name }} defaults: run: shell: bash @@ -74,7 +84,7 @@ jobs: gh workflow run update-cli-docs.yml \ -R temporalio/documentation \ -r main \ - -f cli_release_tag="${{ github.ref_name }}" \ + -f cli_release_tag="${RELEASE_TAG}" \ -f commit_author="${{ steps.get_user.outputs.GIT_NAME }}" \ -f commit_author_email="${{ steps.get_user.outputs.GIT_EMAIL }}" \ - -f commit_message="Update CLI docs for release ${{ github.ref_name }}" + -f commit_message="Update CLI docs for release ${RELEASE_TAG}" From c5eae4c3ca7e324fbc6aa23322888a158be06577 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 14 Sep 2026 14:28:29 -0700 Subject: [PATCH 3/6] Move interpolated values into env: instead of run: bodies secrets.GITHUB_TOKEN and the get_user step outputs were interpolated directly into run: blocks, unlike github.actor in the same file, which already goes through env:. Route them the same way to avoid shell injection from a value GitHub Actions doesn't sanitize before substitution. --- .github/workflows/trigger-docs.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/trigger-docs.yml b/.github/workflows/trigger-docs.yml index 3f6971585..651e95703 100644 --- a/.github/workflows/trigger-docs.yml +++ b/.github/workflows/trigger-docs.yml @@ -48,10 +48,11 @@ jobs: id: get_user env: GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | echo "GitHub actor: ${GITHUB_ACTOR}" # Query the GitHub API for the user's details. - curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ "https://api.github.com/users/${GITHUB_ACTOR}" > user.json # Extract the user's full name if available, default to the username otherwise. @@ -80,11 +81,14 @@ jobs: - name: Trigger Documentation Workflow env: GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + GIT_NAME: ${{ steps.get_user.outputs.GIT_NAME }} + GIT_EMAIL: ${{ steps.get_user.outputs.GIT_EMAIL }} run: | + echo "Dispatching CLI docs update for ${RELEASE_TAG}" gh workflow run update-cli-docs.yml \ -R temporalio/documentation \ -r main \ -f cli_release_tag="${RELEASE_TAG}" \ - -f commit_author="${{ steps.get_user.outputs.GIT_NAME }}" \ - -f commit_author_email="${{ steps.get_user.outputs.GIT_EMAIL }}" \ + -f commit_author="${GIT_NAME}" \ + -f commit_author_email="${GIT_EMAIL}" \ -f commit_message="Update CLI docs for release ${RELEASE_TAG}" From 5039847c404f5f028820b572501ed9dfdbc40f3d Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Tue, 15 Sep 2026 09:40:52 -0700 Subject: [PATCH 4/6] Skip docs generation for a release that isn't the latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prerelease/draft/tag-shape don't rule out a hotfix to an older minor line (patching v1.7.x after v1.8.0 is already GA, for example) — that's a real full release, but generating docs from it would overwrite the newer version's command reference. The release payload has no "is latest" field (confirmed against the live API: a release object exposes prerelease/draft/tag_name/etc., but nothing about latest status), so compare the tag against GET /releases/latest instead, in a step rather than the job-level `if:` since expressions can't make API calls. workflow_dispatch stays exempt, consistent with the existing gate: backfilling an older tag on purpose is a deliberate choice, not something to second-guess. --- .github/workflows/trigger-docs.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/trigger-docs.yml b/.github/workflows/trigger-docs.yml index 651e95703..6df260e25 100644 --- a/.github/workflows/trigger-docs.yml +++ b/.github/workflows/trigger-docs.yml @@ -44,8 +44,27 @@ jobs: run: shell: bash steps: + - name: Check release is the latest release + # prerelease/draft/tag-shape alone don't rule out a hotfix to an older + # minor line (for example patching v1.7.x after v1.8.0 is GA), which is + # a real full release but would overwrite newer docs if it triggered + # generation. The release payload has no "is latest" field, so compare + # against /releases/latest instead. workflow_dispatch is exempt, same + # as the job-level gate: backfilling an older tag on purpose is fine. + if: github.event_name != 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + latest_tag="$(gh api "repos/${{ github.repository }}/releases/latest" --jq .tag_name)" + if [ "$latest_tag" != "$RELEASE_TAG" ]; then + echo "::notice::${RELEASE_TAG} is not the latest release (latest is ${latest_tag}); skipping so older docs don't overwrite newer ones." + echo "IS_LATEST=false" >> "$GITHUB_ENV" + fi + - name: Get user info from GitHub API id: get_user + if: env.IS_LATEST != 'false' env: GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -69,6 +88,7 @@ jobs: - name: Generate token id: generate_token + if: env.IS_LATEST != 'false' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.TEMPORAL_CICD_APP_ID }} @@ -79,6 +99,7 @@ jobs: documentation - name: Trigger Documentation Workflow + if: env.IS_LATEST != 'false' env: GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} GIT_NAME: ${{ steps.get_user.outputs.GIT_NAME }} From 28b801d42f410e4d06426c6525cd03e87f80fcf1 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Tue, 15 Sep 2026 15:15:00 -0700 Subject: [PATCH 5/6] Compare against the highest published version, not the latest marker Comparing the release tag against GET /releases/latest trusts the make_latest marker, which is metadata someone can set independently of version order. An older-line hotfix published with --latest=true would satisfy that equality check while still being exactly the release this gate exists to reject (caught in review: temporalio/cli#1211). List all non-draft, non-prerelease tags and version-sort them instead, so the check reflects actual version order rather than a marker that can be mis-set. --- .github/workflows/trigger-docs.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/trigger-docs.yml b/.github/workflows/trigger-docs.yml index 6df260e25..0e68eeb01 100644 --- a/.github/workflows/trigger-docs.yml +++ b/.github/workflows/trigger-docs.yml @@ -48,17 +48,25 @@ jobs: # prerelease/draft/tag-shape alone don't rule out a hotfix to an older # minor line (for example patching v1.7.x after v1.8.0 is GA), which is # a real full release but would overwrite newer docs if it triggered - # generation. The release payload has no "is latest" field, so compare - # against /releases/latest instead. workflow_dispatch is exempt, same - # as the job-level gate: backfilling an older tag on purpose is fine. + # generation. workflow_dispatch is exempt, same as the job-level gate: + # backfilling an older tag on purpose is fine. + # + # Compare against the highest published GA version rather than + # /releases/latest: that endpoint reflects the make_latest marker, + # which is metadata someone can set (or mis-set) independently of + # version order. An older-line hotfix published with --latest=true + # would satisfy an equality check against /releases/latest while + # still being exactly the release this step exists to reject. if: github.event_name != 'workflow_dispatch' env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - latest_tag="$(gh api "repos/${{ github.repository }}/releases/latest" --jq .tag_name)" - if [ "$latest_tag" != "$RELEASE_TAG" ]; then - echo "::notice::${RELEASE_TAG} is not the latest release (latest is ${latest_tag}); skipping so older docs don't overwrite newer ones." + highest="$(gh api "repos/${{ github.repository }}/releases" --paginate \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' \ + | sort -V | tail -n1)" + if [ "$(printf '%s\n%s\n' "$highest" "$RELEASE_TAG" | sort -V | tail -n1)" != "$RELEASE_TAG" ]; then + echo "::notice::${RELEASE_TAG} is not the highest published GA version (highest is ${highest}); skipping so older docs don't overwrite newer ones." echo "IS_LATEST=false" >> "$GITHUB_ENV" fi From 1b3c6194c56160b3ca36945ba788b4d5f38301b3 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Tue, 15 Sep 2026 15:19:26 -0700 Subject: [PATCH 6/6] Filter side-channel tags out of the highest-version scan The highest-version query only filtered on draft/prerelease, not tag shape. If a side-channel tag (v2.0.0-serverless) were ever published with prerelease: false, sort -V ranks it above the real v2.0.0 (confirmed empirically), so it would win the "highest" comparison and silently skip every future GA release's docs update (caught in review: temporalio/cli#1211). Apply the same strict vX.Y.Z tag-shape filter the job-level gate already uses to the release list before sorting. --- .github/workflows/trigger-docs.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/trigger-docs.yml b/.github/workflows/trigger-docs.yml index 0e68eeb01..ab4ad5ded 100644 --- a/.github/workflows/trigger-docs.yml +++ b/.github/workflows/trigger-docs.yml @@ -57,13 +57,19 @@ jobs: # version order. An older-line hotfix published with --latest=true # would satisfy an equality check against /releases/latest while # still being exactly the release this step exists to reject. + # + # The tag-shape filter here has to match the job-level gate's: a + # side-channel tag (v2.0.0-serverless) that ever slipped through as + # prerelease: false would still sort above the real v2.0.0 under + # sort -V (confirmed: v2.0.0-serverless > v2.0.0), silently skipping + # the real GA release's docs update on every future comparison. if: github.event_name != 'workflow_dispatch' env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail highest="$(gh api "repos/${{ github.repository }}/releases" --paginate \ - --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' \ + --jq '.[] | select(.draft == false and .prerelease == false and (.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))) | .tag_name' \ | sort -V | tail -n1)" if [ "$(printf '%s\n%s\n' "$highest" "$RELEASE_TAG" | sort -V | tail -n1)" != "$RELEASE_TAG" ]; then echo "::notice::${RELEASE_TAG} is not the highest published GA version (highest is ${highest}); skipping so older docs don't overwrite newer ones."