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:
Sven Scharmentke
2026-08-22 17:35:32 +02:00
committed by GitHub
parent 356a6bdfe1
commit 2693042b65
5 changed files with 157 additions and 66 deletions
+7 -57
View File
@@ -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 }}
+91
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
".": "1.0.1"
}
+45 -9
View File
@@ -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
+11
View File
@@ -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
}
}
}