-
Notifications
You must be signed in to change notification settings - Fork 0
207 lines (186 loc) · 9.42 KB
/
Copy pathrelease.yml
File metadata and controls
207 lines (186 loc) · 9.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
name: release
# Automated release pipeline for `mnemoverse` (PyPI).
#
# Mirrors mcp-memory-server/.github/workflows/release.yml deliberately: the
# same tag trigger, the same version-match guard, the same refusal to publish
# anything the guards have not checked. Different package, same discipline.
#
# TO RELEASE:
# 1. Bump the version in pyproject.toml AND mnemoverse/__init__.py.
# 2. Move CHANGELOG.md's [Unreleased] content under `## [X.Y.Z] — DATE`.
# 3. Open a PR, get it green, MERGE it.
# 4. git pull && git tag vX.Y.Z && git push origin vX.Y.Z <- tags the merge commit
# Steps 1-3 are manual on purpose; the guards below exist because they are.
#
# WHY THIS EXISTS: 0.2.0 shipped two merged fixes that had sat unpublished on
# main — including a circuit-breaker bug that blocked valid writes. Merged is
# not delivered. Publishing by hand is how that gap opens, so it is automated.
#
# Trigger: push a semver tag `vX.Y.Z` to main (e.g. `git tag v0.2.0 && git push origin v0.2.0`).
#
# In order:
# 1. Refuse a tag that is not an ancestor of main — an unmerged branch must
# not reach an irreversible index.
# 2. Check the tag against pyproject.toml, mnemoverse/__version__ AND the
# CHANGELOG heading; report every mismatch, not the first.
# 3. Install dev deps, run pytest, run mypy (strict, NOT advisory).
# 4. Build the sdist + wheel with hatchling, then twine check.
# 5. Install the BUILT WHEEL into a clean venv and assert what only the
# artifact can prove: it imports without dev deps, it carries py.typed,
# and every name in __all__ is really exported.
# 6. Publish to PyPI using the PYPI_API_TOKEN repo secret (project-scoped
# token from pypi.org/manage/account/token). Same shape as the sibling
# npm package's NPM_TOKEN. Trusted Publishing (OIDC) is the stronger
# option and the step is one `with:` block away from it — see the
# comment on the publish step.
# 7. Read back pypi.org/pypi/mnemoverse/json and assert the version is served.
# 8. gh release create with auto-generated notes.
#
# PyPI is IRREVERSIBLE: a version number can never be reused, even after
# deletion. The version-match guard in step 1 is the only thing between a typo
# and a permanently burned version.
on:
push:
tags:
- "v*"
permissions:
contents: write # gh release create
# id-token is NOT granted: this workflow authenticates to PyPI with a stored
# token, so an OIDC identity would be authority it never uses. Restore it if
# the publish step ever moves to Trusted Publishing.
id-token: none
# A tag pushed twice, or two tags in quick succession, would race at the
# publish step and the loser fails with a 400. Never cancel in flight: a
# half-run release is worse than a queued one.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the ancestry check below needs real history
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# The tag must be ON MAIN. Without this, `git tag v0.2.0` on an unmerged
# branch passes every other check and publishes irreversibly from code
# that was never reviewed. Caught in the 0.2.0 review.
- name: Tag must be an ancestor of main
run: |
set -euo pipefail
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::tag $GITHUB_REF_NAME points at $GITHUB_SHA, which is not on main — merge the PR first, then tag the merge commit"
exit 1
fi
# The version lives in THREE places. The sibling npm package shipped a
# release PR with a stale lockfile because its guard checked one file
# (mcp-memory-server#81) — same shape, so this one checks all three.
- name: Tag must match pyproject, __version__ and the CHANGELOG heading
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME#v}"
PY_PROJECT=$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)
DUNDER=$(grep -m1 '^__version__' mnemoverse/__init__.py | cut -d'"' -f2)
echo "tag=$TAG pyproject=$PY_PROJECT __version__=$DUNDER"
FAIL=0
[ "$TAG" = "$PY_PROJECT" ] || { echo "::error::pyproject.toml reads $PY_PROJECT, tag expects $TAG"; FAIL=1; }
[ "$TAG" = "$DUNDER" ] || { echo "::error::__version__ reads $DUNDER, tag expects $TAG"; FAIL=1; }
grep -q "^## \[$TAG\]" CHANGELOG.md || { echo "::error::CHANGELOG.md has no '## [$TAG]' section — an unreleased version must not be published"; FAIL=1; }
[ "$FAIL" -eq 0 ] || exit 1
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install build twine
- name: Test
run: python -m pytest -q
- name: Typecheck
run: python -m mypy mnemoverse
- name: Build
run: python -m build
- name: Check artifacts
run: python -m twine check dist/*
# EXERCISE THE ARTIFACT, not the source tree. Everything above this line
# imports `mnemoverse` from the editable install, so anything that fails
# to make it INTO the wheel is invisible — which is exactly how 0.2.0
# nearly shipped a `Typing :: Typed` classifier with no py.typed marker,
# silently degrading every consumer's type checker to `Any`. twine check
# validates METADATA renderability, never contents.
- name: The built wheel must be installable and honest
run: |
set -euo pipefail
python -m venv /tmp/verify
/tmp/verify/bin/python -m pip install --quiet --force-reinstall dist/*.whl
/tmp/verify/bin/python - <<'PY'
import importlib.resources as res
import mnemoverse
# Imports with no dev dependencies present: catches a runtime import
# declared only in the dev extra.
print("version:", mnemoverse.__version__)
# The typing claim in the classifier must be backed by the marker.
assert (res.files("mnemoverse") / "py.typed").is_file(), \
"wheel declares Typing :: Typed but ships no py.typed marker"
# Every name the package advertises must actually be importable.
missing = [n for n in mnemoverse.__all__ if not hasattr(mnemoverse, n)]
assert not missing, f"__all__ advertises names the wheel does not export: {missing}"
print("py.typed present; __all__ exports:", len(mnemoverse.__all__))
PY
# A malformed secret must say so in one line, not in a 20-line traceback.
# v0.2.0's first attempt died inside requests with
# `UnicodeEncodeError: 'latin-1' codec can't encode characters in
# position 0-1` — the stored token began with two non-ASCII characters
# (a paste that picked up Cyrillic or invisible ones). Nothing was
# uploaded, but the cause took a log dig to find. PyPI tokens are ASCII
# and start with `pypi-`; check that before handing it to the uploader.
- name: The stored token must look like a PyPI token
env:
PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
set -euo pipefail
if [ -z "${PYPI_TOKEN:-}" ]; then
echo "::error::PYPI_API_TOKEN is empty or unset — add it at Settings > Secrets and variables > Actions"
exit 1
fi
if printf '%s' "$PYPI_TOKEN" | LC_ALL=C grep -q '[^ -~]'; then
echo "::error::PYPI_API_TOKEN contains non-ASCII characters — re-copy the token from pypi.org; a paste picked up invisible or Cyrillic characters"
exit 1
fi
case "$PYPI_TOKEN" in
pypi-*) echo "token shape ok (${#PYPI_TOKEN} chars, pypi- prefix)" ;;
*) echo "::error::PYPI_API_TOKEN does not start with 'pypi-' — that is not a PyPI API token"; exit 1 ;;
esac
# Auth is a repo secret, mirroring the sibling npm package's NPM_TOKEN.
# Trusted Publishing (OIDC, no stored secret) is the better shape and the
# action supports it by simply omitting `password:` — switch by deleting
# the `with:` block below and registering the publisher on pypi.org.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
# Read back what the index actually serves. Mirrors the sibling's
# registry check (mcp-memory-server release.yml): a publish step that
# exits 0 is a claim, not a confirmation.
- name: PyPI must serve the new version
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL "https://pypi.org/pypi/mnemoverse/json" \
| python -c "import json,sys; sys.exit(0 if '$VERSION' in json.load(sys.stdin)['releases'] else 1)"; then
echo "pypi serves $VERSION"
exit 0
fi
echo "not visible yet (attempt $attempt), waiting…"
sleep 10
done
echo "::error::published, but pypi.org does not list $VERSION after 60s — check the project page before assuming success"
exit 1
- name: GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "${GITHUB_REF_NAME}" --generate-notes