Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,15 @@ 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. 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
`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
Expand All @@ -195,8 +202,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/<N>-python-calver-<date>`, labeled `v2`. Close and reopen it to start
CI, check that each stamped package really changed, and a maintainer merges it.
`v2/chore/<N>-python-calver-<date>`, labeled `v2`, and dispatches the
TypeScript and Python workflows on that branch, as the Version Packages
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.

⚠️ GitHub only dispatches a workflow that exists on the default branch. Until
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/prepare-python-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 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
Expand All @@ -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
83 changes: 83 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ 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'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),
# except on main, where every merge keeps its own CI run
Expand Down Expand Up @@ -174,3 +182,78 @@ 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"
# `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' && always()
needs:
[
dispatch-status-pending,
detect-packages,
script-tests,
test,
coverage,
test-windows,
build,
]
timeout-minutes: 5
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"
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)" \
-f description="$DESCRIPTION" \
-f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
83 changes: 83 additions & 0 deletions .github/workflows/typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ 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'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),
# except on main, where every merge keeps its own CI run
Expand Down Expand Up @@ -200,3 +208,78 @@ 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"

# `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' && always()
needs:
[
dispatch-status-pending,
root-guards,
detect-packages,
validate,
coverage,
test-windows,
smoke,
]
timeout-minutes: 5
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"
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)" \
-f description="$DESCRIPTION" \
-f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
24 changes: 24 additions & 0 deletions .github/workflows/version-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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
Comment thread
cliffhall marked this conversation as resolved.
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). 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 <branch>` (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

Expand Down
Loading
Loading