mirror of
https://github.com/svnscha/mcp-windbg.git
synced 2026-10-16 20:32:50 +08:00
ci: drive releases with release-please (#82)
Replaces the hand-run release process with [release-please](https://github.com/googleapis/release-please) (`googleapis/release-please-action@v5`). ## The new flow 1. Push to `main` -> release-please opens or rewrites a `chore(main): release X.Y.Z` PR carrying the version bumps. 2. Before merging, add the `CHANGELOG.md` entry to that PR (rename `## [Unreleased]` to `## [X.Y.Z] - date`). 3. Merge -> the tag and GitHub release are created, the release body is set from your CHANGELOG entry, and PyPI publish runs behind the full Windows test matrix. No more editing three files and pushing a tag by hand. ## CHANGELOG stays hand-written `skip-changelog: true`. release-please generates terse bullets from commit subjects; the entries in this repo explain *why* a change happened and credit the people who found it, which is worth more than the automation saves. The `release-notes` job replaces the generated GitHub release body with the CHANGELOG section for that version, preserving what `publish-mcp.yml` used to do. Bonus: because release-please never touches `CHANGELOG.md`, the `## [Unreleased]` heading is a non-issue. It would otherwise have been a trap - release-please finds its insert point with `\n###? v?[0-9[]`, and `[` is inside that character class, so a new release would have been inserted *above* `[Unreleased]`, stranding it. ## Two traps this avoids **Tag-triggered publish would have silently stopped working.** release-please creates the tag with `GITHUB_TOKEN`, and tags pushed by that token do not start workflow runs. `publish-mcp.yml` is now `workflow_call`/`workflow_dispatch` and is invoked via `needs`, so no cross-workflow trigger is involved. Its `create-github-release` job is dropped - release-please owns releases now. **`include-component-in-tag` is set to `false` explicitly.** It defaults to `true` in manifest mode, which with `package-name` set would have tagged `mcp-windbg-v1.1.0` instead of `v1.1.0`. ## A latent bug fixed along the way The awk that extracts the changelog section for the release notes used a dynamic regex. awk treats `"\["` inside a string as a plain `"["`, which collapsed the pattern into a character class matching **every** `## [` heading - it always took the first section regardless of the version requested. It only produced correct notes for v1.0.1 because that heading happened to be on top at tag time; with an `[Unreleased]` section present it would have shipped the wrong content. Rewritten with `index()`/`substr()`, which has no escaping hazard: ```awk awk -v hdr="## [$CLEAN_VERSION]" ' index($0, hdr) == 1 { found = 1; next } found && substr($0, 1, 4) == "## [" { exit } found { print } ' CHANGELOG.md ``` Verified to extract the correct section for both 1.0.1 and 1.0.0, and the job's real shell block was extracted from the YAML and executed locally. ## PR titles now carry meaning Squash-merging makes the PR title the commit subject, which is what release-please reads. It must be a Conventional Commit or the change contributes nothing to the next version. Worth knowing that of the recent merges here, only `fix(deps): pin mcp dependency to <2.0.0` would have counted. `CLAUDE.md` documents the prefix table, the flow, and the two setup requirements. ## Repo setting changed "Allow GitHub Actions to create and approve pull requests" was **off**, which would have made release-please fail with a 403 when opening the release PR. Enabled via the API at your request. `default_workflow_permissions` was left at `read` - each job declares the scopes it needs. ## Verification - Both JSON config files parse; all three workflow YAMLs parse. - The `release-notes` shell block was extracted from the YAML and run against the real `CHANGELOG.md`, producing the correct 1.0.1 body. - 89 hermetic tests still pass. The end-to-end release path cannot be fully proven until the first real release runs - that is the one thing here I have not been able to exercise.
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
name: Publish MCP Server
|
||||
|
||||
# Called by release-please.yml once it has cut the tag and the GitHub release.
|
||||
# It is no longer triggered by a tag push: release-please creates the tag with
|
||||
# GITHUB_TOKEN, and tags pushed by that token do not start workflow runs, so a
|
||||
# `push: tags` trigger here would never fire. workflow_dispatch stays for
|
||||
# publishing by hand if a release ever needs re-shipping.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"] # Triggers on version tags like v1.0.0
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
@@ -66,57 +70,3 @@ jobs:
|
||||
echo 'pip install mcp-windbg' >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-and-test, publish-pypi]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write # Required to create releases
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ needs.build-and-test.outputs.artifact-name }}
|
||||
path: dist/
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract changelog for this version
|
||||
id: changelog
|
||||
run: |
|
||||
# Extract changelog section for this version
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
# Remove 'v' prefix if present for changelog lookup
|
||||
CLEAN_VERSION=${VERSION#v}
|
||||
|
||||
# Extract the section between this version and the next version marker
|
||||
awk -v version="[$CLEAN_VERSION]" '
|
||||
$0 ~ "## \\[" version {found=1; next}
|
||||
found && /^## \[/ {exit}
|
||||
found {print}
|
||||
' CHANGELOG.md > release_notes.md
|
||||
|
||||
# If no specific version found, use auto-generated notes
|
||||
if [ ! -s release_notes.md ]; then
|
||||
echo "## What's Changed" > release_notes.md
|
||||
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Release ${{ steps.version.outputs.VERSION }}
|
||||
body_path: release_notes.md
|
||||
files: dist/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Release Please
|
||||
|
||||
# Release automation, in two phases that both run from this one file.
|
||||
#
|
||||
# 1. Every push to main, release-please recomputes what the next version would
|
||||
# be from the Conventional Commit subjects since the last release, and opens
|
||||
# or rewrites a "chore(main): release X.Y.Z" PR carrying the version bumps
|
||||
# and the CHANGELOG entry.
|
||||
# 2. Merging that PR pushes to main, which runs this workflow again. This time
|
||||
# release-please sees a merged release PR, creates the tag and the GitHub
|
||||
# release, and the publish job below ships it to PyPI.
|
||||
#
|
||||
# The publish job lives in this same workflow on purpose. A release created with
|
||||
# the default GITHUB_TOKEN does not trigger other workflows, so the old
|
||||
# `on: push: tags: [v*]` publish would silently never fire. Calling it via
|
||||
# `needs` sidesteps that entirely - no cross-workflow trigger is involved.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # tags, releases, and the release branch
|
||||
pull-requests: write # open and rewrite the release PR
|
||||
issues: write # the autorelease:* labels are an issues-scope API
|
||||
outputs:
|
||||
# `releases_created` is always set to the string 'true' or 'false'. Compare
|
||||
# it explicitly: any non-empty string is truthy in a GitHub Actions
|
||||
# expression, so a bare `if: ...outputs.releases_created` is always true.
|
||||
releases_created: ${{ steps.release.outputs.releases_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v5
|
||||
id: release
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
# Releases and PRs made with GITHUB_TOKEN do not trigger workflows, so
|
||||
# CI does not run on the release PR itself. Supply a PAT or GitHub App
|
||||
# token here if you want the PR gated by the test matrix before merge.
|
||||
# token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
|
||||
# release-please is configured with skip-changelog, so CHANGELOG.md stays
|
||||
# hand-written - but it still creates the GitHub release with its own notes,
|
||||
# generated from commit subjects. Those are thinner than the entry a human
|
||||
# wrote, so replace them with it.
|
||||
release-notes:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.releases_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Use the hand-written CHANGELOG entry as the release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
CLEAN_VERSION="${TAG#v}"
|
||||
# Matched with index()/substr() rather than a dynamic regex: awk
|
||||
# treats "\[" in a string as a plain "[", which silently turns the
|
||||
# pattern into a character class that matches every "## [" heading,
|
||||
# so the notes become whichever section happens to be first.
|
||||
awk -v hdr="## [$CLEAN_VERSION]" '
|
||||
index($0, hdr) == 1 { found = 1; next }
|
||||
found && substr($0, 1, 4) == "## [" { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md > release_notes.md
|
||||
if [ -s release_notes.md ]; then
|
||||
gh release edit "$TAG" --notes-file release_notes.md
|
||||
echo "Release notes taken from the CHANGELOG entry for $CLEAN_VERSION."
|
||||
else
|
||||
# Not fatal: the release exists either way, and the publish job's
|
||||
# version-consistency check is what actually fails a missing entry.
|
||||
echo "::warning::No CHANGELOG section for $CLEAN_VERSION - keeping the generated notes."
|
||||
fi
|
||||
|
||||
publish:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.releases_created == 'true' }}
|
||||
uses: ./.github/workflows/publish-mcp.yml
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "1.0.1"
|
||||
}
|
||||
@@ -101,16 +101,52 @@ Tool and CLI facts come from `src/mcp_windbg/server.py` (tool schemas) and
|
||||
|
||||
## Versioning and release
|
||||
|
||||
The version lives in three places that must agree: `pyproject.toml` (`version`), `server.json`
|
||||
(top-level `version` and every `packages[*].version`), and the top `## [x.y.z]` heading in
|
||||
`CHANGELOG.md`. Day-to-day work lands under a `## [Unreleased]` heading; the version-
|
||||
consistency check (`scripts/check-version-consistency.ps1`) only runs on release builds, so
|
||||
`main` may sit at `[Unreleased]`.
|
||||
Releases are driven by [release-please](https://github.com/googleapis/release-please). You do
|
||||
not bump versions and you do not push tags. You **do** still write `CHANGELOG.md` by hand: it is
|
||||
configured with `skip-changelog`, so release-please never touches it.
|
||||
|
||||
To release: bump all three to the new version, set the CHANGELOG date, then tag `vX.Y.Z` and
|
||||
push. `publish-mcp.yml` runs the tests, builds, publishes to PyPI, and creates the GitHub
|
||||
release; that workflow runs the consistency check (it calls build-and-test with
|
||||
`check-version: true`).
|
||||
**PR titles decide the version.** Because PRs are squash-merged, the PR title becomes the commit
|
||||
subject, which is what release-please reads. It must be a Conventional Commit:
|
||||
|
||||
| PR title prefix | Bump | Shown in the release notes |
|
||||
| :-------------- | :--- | :------------------------- |
|
||||
| `feat:` | minor | yes |
|
||||
| `fix:` | patch | yes |
|
||||
| `perf:` `docs:` `deps:` | patch | yes |
|
||||
| `chore:` `ci:` `test:` `refactor:` `build:` `style:` | patch | hidden |
|
||||
| any prefix with `!`, or a `BREAKING CHANGE:` footer | **major** | yes |
|
||||
|
||||
A title with no recognized prefix contributes nothing. If everything since the last release is
|
||||
hidden-only (`chore`, `ci`, `test`, ...), release-please opens no release PR at all - by design,
|
||||
not a failure. `Release-As: X.Y.Z` in a commit body forces an exact version.
|
||||
|
||||
**The flow.** Every push to `main` runs `release-please.yml`, which opens or rewrites a
|
||||
`chore(main): release X.Y.Z` PR carrying the version bumps. Before merging it, **add the
|
||||
`CHANGELOG.md` entry to that PR**: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` to match
|
||||
the version in the PR title. Merging pushes to `main`, which runs the workflow again; this time
|
||||
release-please creates the tag and the GitHub release, `release-notes` replaces the generated
|
||||
notes with your CHANGELOG entry, and `publish` calls `publish-mcp.yml` to run the full test
|
||||
matrix and ship to PyPI.
|
||||
|
||||
Forgetting the CHANGELOG entry is caught, but late: `scripts/check-version-consistency.ps1` runs
|
||||
on the release build and fails when the top `## [x.y.z]` heading does not match `pyproject.toml`.
|
||||
The tag and release will already exist, so fix the CHANGELOG on `main` and re-run
|
||||
`publish-mcp.yml` by hand via `workflow_dispatch`.
|
||||
|
||||
**What release-please keeps in sync.** `release-please-config.json` drives it. The `python`
|
||||
release type updates `pyproject.toml`'s `[project] version` natively. `server.json` is not
|
||||
something it knows about, so it is handled by two `extra-files` JSONPath entries (`$.version` and
|
||||
`$.packages[*].version`) - the top-level and per-package versions are separate fields, and one
|
||||
entry carries one JSONPath.
|
||||
|
||||
**Two setup requirements**, easy to lose track of:
|
||||
|
||||
- Settings -> Actions -> General -> "Allow GitHub Actions to create and approve pull requests"
|
||||
must stay enabled, or opening the release PR fails with a 403.
|
||||
- Releases created with the default `GITHUB_TOKEN` do not trigger other workflows. That is why
|
||||
`publish-mcp.yml` is `workflow_call`/`workflow_dispatch` rather than `on: push: tags` - a tag
|
||||
trigger would silently never fire. It is also why CI does not run on the release PR itself;
|
||||
supply a PAT or GitHub App token to `release-please.yml` if you want that gate.
|
||||
|
||||
## Docs
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"release-type": "python",
|
||||
"include-component-in-tag": false,
|
||||
"packages": {
|
||||
".": {
|
||||
"package-name": "mcp-windbg",
|
||||
"skip-changelog": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user