From 2ffe79f5aca1ef3214f8e38b3a5d6f4629630488 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:12:23 -0400 Subject: [PATCH 1/3] ci: dispatch CI on the version PRs that automation opens The Version Packages and Prepare Python Release workflows push their PR branches with the workflow token, which starts no workflow, and since #5048 the TypeScript and Python workflows run on push only, so a close and reopen (which fires pull_request) never started them either. Both CI workflows now also accept workflow_dispatch, the one event that token can start, and both version workflows dispatch them on the PR's branch after opening or updating it. RELEASING.md and the release skill describe that path instead of close-and-reopen. Closes #5110 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 16 +++++++++---- .github/workflows/prepare-python-release.yml | 16 ++++++++++++- .github/workflows/python.yml | 6 +++++ .github/workflows/typescript.yml | 6 +++++ .github/workflows/version-packages.yml | 24 ++++++++++++++++++++ RELEASING.md | 2 +- 6 files changed, 64 insertions(+), 6 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7f334f578a..01c3e7b6b5 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -177,8 +177,14 @@ Then, on the PR: 1. **Tie it to the release issue with a comment**, `Part of #N`. The action rewrites the PR's body on every push to `v2/main`, so a `Closes` line there would not survive; this is the exception `AGENTS.md` names. -2. **Close and reopen it to start CI.** A PR opened with the workflow token - does not trigger workflows. +2. **Wait for its CI to pass.** The workflow token's push starts no workflow, + so the Version Packages workflow dispatches the TypeScript and Python + workflows on `changeset-release/v2/main` after each update; their checks + report on the PR's head commit. Closing and reopening the PR does not start + them (that fires `pull_request`; they run on `push`). If none appear, run + them by hand: + `gh workflow run typescript.yml --ref changeset-release/v2/main` (and + `python.yml`). 3. **Read it**: each bump matches the semver policy in `RELEASING.md`, each `CHANGELOG.md` entry says what a user of the server needs to know, the consumed changeset files are deleted, and `package-lock.json` records the @@ -195,8 +201,10 @@ gh workflow run prepare-python-release.yml --repo modelcontextprotocol/servers \ ``` It opens a PR that starts with `Closes #N`, on a branch named -`v2/chore/-python-calver-`, labeled `v2`. Close and reopen it to start -CI, check that each stamped package really changed, and a maintainer merges it. +`v2/chore/-python-calver-`, labeled `v2`, and dispatches the +TypeScript and Python workflows on that branch, as the Version Packages +workflow does. Wait for that CI to pass, check that each stamped package really +changed, and a maintainer merges it. No PR means no Python server has a shipped change since its last stamp. ⚠️ GitHub only dispatches a workflow that exists on the default branch. Until diff --git a/.github/workflows/prepare-python-release.yml b/.github/workflows/prepare-python-release.yml index 8acd858d89..d6cc064783 100644 --- a/.github/workflows/prepare-python-release.yml +++ b/.github/workflows/prepare-python-release.yml @@ -13,6 +13,12 @@ # milestone's release issue (the `issue` input), its branch is named for that # issue, and it is labeled `v2`. The link is only a cross-reference on # `v2/main`; the release issue stays open until the Release is published. +# +# It pushes the branch with the workflow token, and a push made with that +# token starts no workflow, so the PR would get no TypeScript or Python CI +# (#5110); a close and reopen fires only `pull_request`, which those workflows +# do not run on. So it dispatches both on the branch once the PR is open: +# `workflow_dispatch` is the one event the workflow token can start. name: Prepare Python Release @@ -32,6 +38,8 @@ jobs: permissions: contents: write pull-requests: write + # To dispatch the TypeScript and Python workflows on the PR's branch. + actions: write steps: # The ref this was dispatched on becomes the PR's base, and every PR # targets `v2/main`. A run from `main` would open a version PR straight @@ -100,7 +108,7 @@ jobs: echo "Generated by the **Prepare Python Release** workflow. Nothing is published until a maintainer publishes a GitHub Release." echo echo "> [!NOTE]" - echo "> A PR opened with the workflow token does not trigger CI. Close and reopen this PR to run it." + echo "> A push made with the workflow token starts no CI, so the workflow dispatched the TypeScript and Python workflows on this branch. Their checks report on the head commit. To run them again: \`gh workflow run python.yml --ref $BRANCH\` (and \`typescript.yml\`)." } > "$RUNNER_TEMP/pr-body.md" # A second run on the same day force-updates the branch, and may # stamp a different set of packages; the open PR's body is rewritten @@ -117,3 +125,9 @@ jobs: --title "chore: stamp Python CalVer versions for $DATE" \ --body-file "$RUNNER_TEMP/pr-body.md" fi + # The push above starts no workflow (see the header), so CI is + # dispatched on the branch explicitly, after a first run and a rerun + # alike. + for workflow in typescript.yml python.yml; do + gh workflow run "$workflow" --ref "$BRANCH" + done diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d6180243a5..57697d6de4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -5,6 +5,12 @@ on: # PR branch ran each workflow twice (once per event). PR checks still report # from the push run of the head commit. push: + # A push made with the workflow token starts no workflow, so the version PRs + # that automation opens (Version Packages, Prepare Python Release) get no + # push run. Their workflows dispatch this one on the PR's branch instead + # (#5110): `workflow_dispatch` is the one event that token can start, and its + # run reports its checks on the branch's head commit, which the PR shows. + workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), # except on main, where every merge keeps its own CI run diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 2a909fa4bb..d5d3907f51 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -5,6 +5,12 @@ on: # PR branch ran each workflow twice (once per event). PR checks still report # from the push run of the head commit. push: + # A push made with the workflow token starts no workflow, so the version PRs + # that automation opens (Version Packages, Prepare Python Release) get no + # push run. Their workflows dispatch this one on the PR's branch instead + # (#5110): `workflow_dispatch` is the one event that token can start, and its + # run reports its checks on the branch's head commit, which the PR shows. + workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), # except on main, where every merge keeps its own CI run diff --git a/.github/workflows/version-packages.yml b/.github/workflows/version-packages.yml index 15c19b1f22..004e7a9f0e 100644 --- a/.github/workflows/version-packages.yml +++ b/.github/workflows/version-packages.yml @@ -14,6 +14,13 @@ # it as an exception and says how it is tied to its release issue instead. The # `v2` label does survive an update, and this workflow applies it. # +# The action pushes the PR's branch with the workflow token, and a push made +# with that token starts no workflow, so the PR would get no TypeScript or +# Python CI (#5110). Closing and reopening it does not help either: that fires +# `pull_request`, and those workflows run on `push`. So after each update this +# workflow dispatches both on the PR's branch; `workflow_dispatch` is the one +# event the workflow token can start. +# # This workflow only ever opens a PR. It gives the action no publish script, so # it never publishes, tags or creates a Release; that is `release.yml`, on a # maintainer-published GitHub Release. @@ -34,6 +41,8 @@ jobs: permissions: contents: write pull-requests: write + # To dispatch the TypeScript and Python workflows on the PR's branch. + actions: write steps: - uses: actions/checkout@v6 @@ -68,3 +77,18 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ steps.changesets.outputs.pr-number }} run: gh pr edit "$PR" --add-label v2 + + # The action pushed the branch with the workflow token, which starts no + # workflow, so CI is dispatched on it explicitly (see the header). A + # dispatch on every update matches a push run on every push: the CI + # workflows' concurrency groups cancel the superseded run. + - name: Run CI on the Version Packages PR + if: steps.changesets.outputs.pr-number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ steps.changesets.outputs.pr-number }} + run: | + BRANCH=$(gh pr view "$PR" --json headRefName --jq .headRefName) + for workflow in typescript.yml python.yml; do + gh workflow run "$workflow" --ref "$BRANCH" + done diff --git a/RELEASING.md b/RELEASING.md index fa33776762..e84aaf64d4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,7 +39,7 @@ node scripts/prepare-python-release.mjs # prints one "name: old -> new" line ### PRs opened by these workflows > [!NOTE] -> Both workflows open their PR with the workflow token, and GitHub does not start CI for a PR opened that way. Close and reopen the PR to run CI before merging. The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR. +> Both workflows push their PR's branch with the workflow token, and a push made with that token starts no workflow, so each one then dispatches the TypeScript and Python workflows on that branch (`workflow_dispatch` is the one event the token can start). Their checks report on the PR's head commit; wait for them to pass before merging. Closing and reopening the PR does not start them: that fires `pull_request`, and those workflows run on `push`. To run them again by hand: `gh workflow run typescript.yml --ref ` (and `python.yml`). The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR. ## How publishing works From 0b58b3332615a17e3a3711dfd09d0df3bffde00f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:23:54 -0400 Subject: [PATCH 2/3] ci: report dispatched CI runs on the PR as commit statuses A workflow_dispatch run's checks attach to the head commit but are left out of its status rollup, which is what a pull request reads: a probe commit with 29 successful dispatched check runs had a rollup of null. So a version PR would have shown no CI at all. On a dispatch, each CI workflow now posts a pending commit status at the start and the run's result at the end, which the rollup does include. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 11 ++-- .github/workflows/prepare-python-release.yml | 2 +- .github/workflows/python.yml | 63 +++++++++++++++++++- .github/workflows/typescript.yml | 63 +++++++++++++++++++- RELEASING.md | 2 +- 5 files changed, 132 insertions(+), 9 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 01c3e7b6b5..f56c8bfba9 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -179,10 +179,11 @@ Then, on the PR: would not survive; this is the exception `AGENTS.md` names. 2. **Wait for its CI to pass.** The workflow token's push starts no workflow, so the Version Packages workflow dispatches the TypeScript and Python - workflows on `changeset-release/v2/main` after each update; their checks - report on the PR's head commit. Closing and reopening the PR does not start - them (that fires `pull_request`; they run on `push`). If none appear, run - them by hand: + workflows on `changeset-release/v2/main` after each update. A PR does not + list dispatched checks, so the result shows as two commit statuses, + `TypeScript (dispatched)` and `Python (dispatched)`. Closing and reopening + the PR does not start them (that fires `pull_request`; they run on `push`). + If neither status appears, run them by hand: `gh workflow run typescript.yml --ref changeset-release/v2/main` (and `python.yml`). 3. **Read it**: each bump matches the semver policy in `RELEASING.md`, each @@ -203,7 +204,7 @@ gh workflow run prepare-python-release.yml --repo modelcontextprotocol/servers \ It opens a PR that starts with `Closes #N`, on a branch named `v2/chore/-python-calver-`, labeled `v2`, and dispatches the TypeScript and Python workflows on that branch, as the Version Packages -workflow does. Wait for that CI to pass, check that each stamped package really +workflow does. Wait for its two `(dispatched)` statuses to pass, check that each stamped package really changed, and a maintainer merges it. No PR means no Python server has a shipped change since its last stamp. diff --git a/.github/workflows/prepare-python-release.yml b/.github/workflows/prepare-python-release.yml index d6cc064783..bfb6c216f9 100644 --- a/.github/workflows/prepare-python-release.yml +++ b/.github/workflows/prepare-python-release.yml @@ -108,7 +108,7 @@ jobs: echo "Generated by the **Prepare Python Release** workflow. Nothing is published until a maintainer publishes a GitHub Release." echo echo "> [!NOTE]" - echo "> A push made with the workflow token starts no CI, so the workflow dispatched the TypeScript and Python workflows on this branch. Their checks report on the head commit. To run them again: \`gh workflow run python.yml --ref $BRANCH\` (and \`typescript.yml\`)." + echo "> A push made with the workflow token starts no CI, so the workflow dispatched the TypeScript and Python workflows on this branch. Their results show on this PR as the \`TypeScript (dispatched)\` and \`Python (dispatched)\` statuses. To run them again: \`gh workflow run python.yml --ref $BRANCH\` (and \`typescript.yml\`)." } > "$RUNNER_TEMP/pr-body.md" # A second run on the same day force-updates the branch, and may # stamp a different set of packages; the open PR's body is rewritten diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 57697d6de4..49f8f056f5 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -9,7 +9,9 @@ on: # that automation opens (Version Packages, Prepare Python Release) get no # push run. Their workflows dispatch this one on the PR's branch instead # (#5110): `workflow_dispatch` is the one event that token can start, and its - # run reports its checks on the branch's head commit, which the PR shows. + # run's checks attach to the branch's head commit. The rollup a PR reads + # leaves dispatched checks out, so the `dispatch-status` jobs below report + # the run as a commit status instead. workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), @@ -180,3 +182,62 @@ jobs: with: name: dist-${{ matrix.package }} path: src/${{ matrix.package }}/dist/ + + # A dispatched run's checks attach to the branch's head commit but are left + # out of its status rollup, which is what a pull request reads (verified on + # #5110: 29 successful dispatched check runs, a rollup of null). So on a + # dispatch, which is how a version PR opened by automation gets CI, these two + # jobs mirror the run onto the commit as a status, which the rollup does + # include: pending at the start, then the result of every job above. A push + # run reports through its own checks and skips both. + dispatch-status-pending: + name: Report the dispatched run as pending + if: github.event_name == 'workflow_dispatch' + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set the commit status to pending + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state=pending -f context="Python (dispatched)" \ + -f description="Dispatched run in progress" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + + # `!cancelled()`: a run superseded by a newer dispatch posts nothing, and the + # newer run reports instead. A job skipped by its own `if:` counts as passed. + dispatch-status: + name: Report the dispatched run's result + if: github.event_name == 'workflow_dispatch' && !cancelled() + needs: + [ + dispatch-status-pending, + detect-packages, + script-tests, + test, + coverage, + test-windows, + build, + ] + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set the commit status to the run's result + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEEDS: ${{ toJSON(needs) }} + run: | + if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then + STATE=success DESCRIPTION="Every job passed" + else + STATE=failure DESCRIPTION="A job failed; open the run for details" + fi + gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state="$STATE" -f context="Python (dispatched)" \ + -f description="$DESCRIPTION" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index d5d3907f51..be8cecd386 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -9,7 +9,9 @@ on: # that automation opens (Version Packages, Prepare Python Release) get no # push run. Their workflows dispatch this one on the PR's branch instead # (#5110): `workflow_dispatch` is the one event that token can start, and its - # run reports its checks on the branch's head commit, which the PR shows. + # run's checks attach to the branch's head commit. The rollup a PR reads + # leaves dispatched checks out, so the `dispatch-status` jobs below report + # the run as a commit status instead. workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), @@ -206,3 +208,62 @@ jobs: - name: Boot every server run: npm run smoke + + # A dispatched run's checks attach to the branch's head commit but are left + # out of its status rollup, which is what a pull request reads (verified on + # #5110: 29 successful dispatched check runs, a rollup of null). So on a + # dispatch, which is how a version PR opened by automation gets CI, these two + # jobs mirror the run onto the commit as a status, which the rollup does + # include: pending at the start, then the result of every job above. A push + # run reports through its own checks and skips both. + dispatch-status-pending: + name: Report the dispatched run as pending + if: github.event_name == 'workflow_dispatch' + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set the commit status to pending + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state=pending -f context="TypeScript (dispatched)" \ + -f description="Dispatched run in progress" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + + # `!cancelled()`: a run superseded by a newer dispatch posts nothing, and the + # newer run reports instead. A job skipped by its own `if:` counts as passed. + dispatch-status: + name: Report the dispatched run's result + if: github.event_name == 'workflow_dispatch' && !cancelled() + needs: + [ + dispatch-status-pending, + root-guards, + detect-packages, + validate, + coverage, + test-windows, + smoke, + ] + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set the commit status to the run's result + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEEDS: ${{ toJSON(needs) }} + run: | + if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then + STATE=success DESCRIPTION="Every job passed" + else + STATE=failure DESCRIPTION="A job failed; open the run for details" + fi + gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state="$STATE" -f context="TypeScript (dispatched)" \ + -f description="$DESCRIPTION" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" diff --git a/RELEASING.md b/RELEASING.md index e84aaf64d4..9164b4cd2e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,7 +39,7 @@ node scripts/prepare-python-release.mjs # prints one "name: old -> new" line ### PRs opened by these workflows > [!NOTE] -> Both workflows push their PR's branch with the workflow token, and a push made with that token starts no workflow, so each one then dispatches the TypeScript and Python workflows on that branch (`workflow_dispatch` is the one event the token can start). Their checks report on the PR's head commit; wait for them to pass before merging. Closing and reopening the PR does not start them: that fires `pull_request`, and those workflows run on `push`. To run them again by hand: `gh workflow run typescript.yml --ref ` (and `python.yml`). The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR. +> Both workflows push their PR's branch with the workflow token, and a push made with that token starts no workflow, so each one then dispatches the TypeScript and Python workflows on that branch (`workflow_dispatch` is the one event the token can start). A PR does not list dispatched checks, so each dispatched run also reports itself on the PR's head commit as a status, `TypeScript (dispatched)` and `Python (dispatched)`; wait for both to pass before merging. Closing and reopening the PR does not start them: that fires `pull_request`, and those workflows run on `push`. To run them again by hand: `gh workflow run typescript.yml --ref ` (and `python.yml`). The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR. ## How publishing works From 521ddb24e318e55be50a3f087d2e56498449af9c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:34:51 -0400 Subject: [PATCH 3/3] ci: finalize a cancelled dispatched run's status, and pin the path A dispatched run cancelled for any reason other than supersession left its "(dispatched)" status pending forever (Copilot). The result job now runs under always(): it stays silent only when a newer dispatched run of the same workflow exists on the commit, and otherwise reports the cancellation as an error. scripts/version-pr-ci.test.mjs pins each link of the version-PR CI path, since nothing exercises it before a release. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/python.yml | 24 +++++++-- .github/workflows/typescript.yml | 24 +++++++-- scripts/version-pr-ci.test.mjs | 86 ++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 scripts/version-pr-ci.test.mjs diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 49f8f056f5..5fb8894779 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -207,11 +207,13 @@ jobs: -f description="Dispatched run in progress" \ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - # `!cancelled()`: a run superseded by a newer dispatch posts nothing, and the - # newer run reports instead. A job skipped by its own `if:` counts as passed. + # `always()`, so a cancelled run cannot leave the status pending. A run that + # was cancelled because a newer dispatch on the same commit superseded it + # posts nothing, and the newer run reports instead; any other cancellation + # is reported as an error. A job skipped by its own `if:` counts as passed. dispatch-status: name: Report the dispatched run's result - if: github.event_name == 'workflow_dispatch' && !cancelled() + if: github.event_name == 'workflow_dispatch' && always() needs: [ dispatch-status-pending, @@ -226,16 +228,30 @@ jobs: runs-on: ubuntu-latest permissions: statuses: write + # To look for a newer dispatched run of this workflow on the commit. + actions: read steps: - name: Set the commit status to the run's result env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} NEEDS: ${{ toJSON(needs) }} run: | if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then STATE=success DESCRIPTION="Every job passed" - else + elif jq -e 'any(.[]; .result == "failure")' <<<"$NEEDS" >/dev/null; then STATE=failure DESCRIPTION="A job failed; open the run for details" + else + NEWER=$(gh run list --workflow python.yml --event workflow_dispatch \ + --commit "$GITHUB_SHA" --json databaseId \ + --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 + # A failed lookup counts as no newer run: an error status is + # better than one left pending. + if [ "${NEWER:-0}" -gt 0 ]; then + echo "Superseded by a newer dispatched run, which reports instead" + exit 0 + fi + STATE=error DESCRIPTION="The run was cancelled; dispatch it again" fi gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ -f state="$STATE" -f context="Python (dispatched)" \ diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index be8cecd386..5bd7bd7911 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -233,11 +233,13 @@ jobs: -f description="Dispatched run in progress" \ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - # `!cancelled()`: a run superseded by a newer dispatch posts nothing, and the - # newer run reports instead. A job skipped by its own `if:` counts as passed. + # `always()`, so a cancelled run cannot leave the status pending. A run that + # was cancelled because a newer dispatch on the same commit superseded it + # posts nothing, and the newer run reports instead; any other cancellation + # is reported as an error. A job skipped by its own `if:` counts as passed. dispatch-status: name: Report the dispatched run's result - if: github.event_name == 'workflow_dispatch' && !cancelled() + if: github.event_name == 'workflow_dispatch' && always() needs: [ dispatch-status-pending, @@ -252,16 +254,30 @@ jobs: runs-on: ubuntu-latest permissions: statuses: write + # To look for a newer dispatched run of this workflow on the commit. + actions: read steps: - name: Set the commit status to the run's result env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} NEEDS: ${{ toJSON(needs) }} run: | if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then STATE=success DESCRIPTION="Every job passed" - else + elif jq -e 'any(.[]; .result == "failure")' <<<"$NEEDS" >/dev/null; then STATE=failure DESCRIPTION="A job failed; open the run for details" + else + NEWER=$(gh run list --workflow typescript.yml --event workflow_dispatch \ + --commit "$GITHUB_SHA" --json databaseId \ + --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 + # A failed lookup counts as no newer run: an error status is + # better than one left pending. + if [ "${NEWER:-0}" -gt 0 ]; then + echo "Superseded by a newer dispatched run, which reports instead" + exit 0 + fi + STATE=error DESCRIPTION="The run was cancelled; dispatch it again" fi gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ -f state="$STATE" -f context="TypeScript (dispatched)" \ diff --git a/scripts/version-pr-ci.test.mjs b/scripts/version-pr-ci.test.mjs new file mode 100644 index 0000000000..227d7781f4 --- /dev/null +++ b/scripts/version-pr-ci.test.mjs @@ -0,0 +1,86 @@ +// Pins the path by which a version PR opened by automation gets CI (#5110). +// +// The Version Packages and Prepare Python Release workflows push their PR +// branches with the workflow token, which starts no workflow, so they +// dispatch `typescript.yml` and `python.yml` on the branch instead. A +// dispatched run's checks are left out of the status rollup a pull request +// reads, so each CI workflow also mirrors a dispatched run onto the commit as +// a status. Both version workflows run only from `v2/main`, so nothing +// exercises this path before a release needs it; if any link here breaks, +// version PRs silently go back to having no visible CI. Each test names the +// link it guards. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse } from "yaml"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const read = (rel) => readFileSync(path.join(repoRoot, rel), "utf8"); +const workflow = (name) => parse(read(`.github/workflows/${name}`)); + +const CI = [ + { file: "typescript.yml", context: "TypeScript (dispatched)" }, + { file: "python.yml", context: "Python (dispatched)" }, +]; +const VERSION_WORKFLOWS = [ + { file: "version-packages.yml", job: "version" }, + { file: "prepare-python-release.yml", job: "prepare" }, +]; + +for (const { file, context } of CI) { + const wf = workflow(file); + const jobs = wf.jobs; + const pending = jobs["dispatch-status-pending"]; + const result = jobs["dispatch-status"]; + + test(`${file} runs on push and can be dispatched`, () => { + assert.ok("push" in wf.on, "push trigger"); + assert.ok("workflow_dispatch" in wf.on, "workflow_dispatch trigger"); + }); + + test(`${file} reports a dispatched run as pending, then its result`, () => { + for (const job of [pending, result]) { + assert.ok(job, "both status jobs exist"); + assert.match(job.if, /github\.event_name == 'workflow_dispatch'/); + assert.equal(job.permissions.statuses, "write"); + const run = job.steps.map((s) => s.run ?? "").join("\n"); + assert.match(run, /statuses\/\$GITHUB_SHA/); + assert.ok(run.includes(`context="${context}"`), `context ${context}`); + } + }); + + test(`${file}'s result job waits on every other job, even after a cancel`, () => { + const others = Object.keys(jobs).filter((id) => id !== "dispatch-status"); + assert.deepEqual([...result.needs].sort(), others.sort()); + // `always()`: a cancelled run must not leave the status pending. + assert.match(result.if, /always\(\)/); + assert.equal(result.permissions.actions, "read"); + const run = result.steps.map((s) => s.run ?? "").join("\n"); + assert.ok(run.includes(`gh run list --workflow ${file}`)); + assert.match(run, /STATE=error/); + }); +} + +for (const { file, job } of VERSION_WORKFLOWS) { + const steps = workflow(file).jobs[job]; + + test(`${file} dispatches both CI workflows on its PR's branch`, () => { + assert.equal(steps.permissions.actions, "write"); + const run = steps.steps.map((s) => s.run ?? "").join("\n"); + assert.match(run, /gh workflow run "\$workflow" --ref "\$BRANCH"/); + assert.match(run, /for workflow in typescript\.yml python\.yml; do/); + }); +} + +test("RELEASING.md names the statuses a version PR is merged on", () => { + const releasing = read("RELEASING.md"); + for (const { context } of CI) + assert.ok(releasing.includes(`\`${context}\``), context); + assert.doesNotMatch(releasing, /Close and reopen the PR to run CI/); +});