Skip to content

Commit 9ed8915

Browse files
committed
fix: harden tag_release and add release docs
Fix three bugs in tag_release.py: - _github_anchor now matches github-slugger (strips brackets, removes non-alphanumeric punctuation, replaces whitespace with hyphens) - Tag deletion deferred until after changelog extraction succeeds, preventing orphaned tag loss on validation failure - Force push instruction emitted when --force flag is used Add docs/RELEASING.md with step-by-step release workflow (version bump, changelog generation, benchmarks, tagging, crates.io publish). Add CHANGELOG.md reference to README.md design goals section.
1 parent 5475651 commit 9ed8915

4 files changed

Lines changed: 298 additions & 14 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ while keeping the API intentionally small and explicit.
3636
- ✅ Stack storage only (no heap allocation in core types)
3737
-`unsafe` forbidden
3838

39+
See [CHANGELOG.md](CHANGELOG.md) for details.
40+
3941
## 🚫 Anti-goals
4042

4143
- Bare-metal performance: see [`blas-src`](https://crates.io/crates/blas-src), [`lapack-src`](https://crates.io/crates/lapack-src), [`openblas-src`](https://crates.io/crates/openblas-src)

docs/RELEASING.md

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Releasing la-stack
2+
3+
This guide documents the exact commands for performing a clean release using a
4+
dedicated release PR, followed by tagging, publishing to crates.io, and
5+
creating a GitHub release.
6+
7+
Applies to versions vX.Y.Z. Prefer updating documentation before publishing
8+
to crates.io.
9+
10+
---
11+
12+
## Conventions and environment
13+
14+
Set these variables to avoid repeating the version string:
15+
16+
```bash
17+
# tag has the leading v, version does not
18+
TAG=vX.Y.Z
19+
VERSION=${TAG#v}
20+
```
21+
22+
Verify your git remotes:
23+
24+
```bash
25+
git remote -v
26+
```
27+
28+
Ensure your local main is up to date before beginning:
29+
30+
```bash
31+
git checkout main
32+
git pull --ff-only
33+
```
34+
35+
---
36+
37+
## Step 1: Create a clean release PR
38+
39+
This PR should primarily include: version bumps, changelog updates, and
40+
documentation updates. All major code changes should already be on main.
41+
42+
**Exception:** Small, critical fixes discovered during the release process
43+
(e.g., documentation errors, script bugs, formatting issues) may be included
44+
but should be minimal and release-critical only.
45+
46+
1. Create the release branch
47+
48+
```bash
49+
git checkout -b release/$TAG
50+
```
51+
52+
2. Bump versions
53+
54+
Preferred (if cargo-edit is installed):
55+
56+
```bash
57+
# Bump package version in Cargo.toml
58+
cargo set-version $VERSION
59+
```
60+
61+
Alternative: edit `Cargo.toml` manually (update `version = "..."` under
62+
`[package]`).
63+
64+
Update references in documentation (search, then manually edit as needed):
65+
66+
```bash
67+
# List occurrences of version-like strings to review
68+
rg -n "\bv?[0-9]+\.[0-9]+\.[0-9]+\b" README.md docs/ || true
69+
```
70+
71+
3. Generate changelog using a temporary local tag (DO NOT PUSH this tag)
72+
73+
```bash
74+
# Create a temporary annotated tag locally to enable changelog generation
75+
# Do not push this tag; it will be recreated later after merge
76+
git tag -a "$TAG" -m "la-stack $TAG"
77+
78+
# Generate changelog (git-cliff + post-processing)
79+
just changelog
80+
```
81+
82+
4. Run benchmarks and update the README comparison table
83+
84+
```bash
85+
# Run vs_linalg benchmarks (la-stack vs nalgebra vs faer) and update the
86+
# README benchmark table + SVG plot
87+
just bench-vs-linalg
88+
just plot-vs-linalg-readme
89+
```
90+
91+
Review the updated table in `README.md` and the plot in `docs/assets/` for
92+
accuracy.
93+
94+
5. Stage and commit release artifacts
95+
96+
```bash
97+
git add Cargo.toml Cargo.lock CHANGELOG.md README.md docs/
98+
99+
git commit -m "chore(release): release $TAG
100+
101+
- Bump version to $TAG
102+
- Update changelog with latest changes
103+
- Update benchmark comparison table
104+
- Update documentation for release"
105+
```
106+
107+
6. Push the branch and open a PR
108+
109+
```bash
110+
git push -u origin "release/$TAG"
111+
```
112+
113+
PR metadata:
114+
115+
- Title: chore(release): release $TAG
116+
- Description: Clean release PR with version bump, changelog, and
117+
documentation updates. No code changes.
118+
119+
Note: Do NOT push the temporary tag created in step 3.
120+
121+
### Handling fixes discovered during release process
122+
123+
If you discover issues (bugs, formatting problems, etc.) after creating the
124+
changelog:
125+
126+
1. **For critical fixes that must be in this release:**
127+
128+
```bash
129+
# Make your fixes
130+
# Run code quality tools
131+
# Commit the fixes
132+
git add .
133+
git commit -m "fix: [description of fix]"
134+
135+
# Delete the temporary tag and regenerate changelog
136+
git tag -d "$TAG"
137+
git tag -a "$TAG" -m "la-stack $TAG"
138+
just changelog
139+
140+
# Commit updated changelog
141+
git add CHANGELOG.md
142+
git commit -m "docs: update changelog with release fixes"
143+
```
144+
145+
2. **For non-critical fixes:**
146+
- Document them as known issues in the release notes
147+
- Include them in the next release
148+
- This avoids the changelog regeneration loop
149+
150+
---
151+
152+
## Step 2: After the PR is merged into main
153+
154+
1. Sync your local main to the merge commit
155+
156+
```bash
157+
git checkout main
158+
git pull --ff-only
159+
```
160+
161+
2. Recreate the final annotated tag using the changelog content
162+
163+
```bash
164+
# Remove the temporary local tag if it exists
165+
git tag -d "$TAG" 2>/dev/null || true
166+
167+
# Create the final annotated tag with the changelog section as the tag message
168+
# Note: For large changelogs (>125KB), this automatically creates an annotated
169+
# tag with a reference message pointing to CHANGELOG.md instead of the full
170+
# content
171+
just tag "$TAG"
172+
```
173+
174+
3. (Optional) Verify tag message content
175+
176+
```bash
177+
git tag -l --format='%(contents)' "$TAG"
178+
```
179+
180+
4. Push the tag
181+
182+
```bash
183+
git push origin "$TAG"
184+
```
185+
186+
5. Create the GitHub release with notes from the tag annotation
187+
188+
```bash
189+
# Requires GitHub CLI (gh) and authenticated session
190+
gh release create "$TAG" --notes-from-tag
191+
```
192+
193+
6. Publish to crates.io
194+
195+
```bash
196+
# Sanity check before publishing
197+
cargo publish --dry-run
198+
199+
# Publish the crate (ensure docs are already updated on main via the PR)
200+
cargo publish
201+
```
202+
203+
---
204+
205+
## Notes and tips
206+
207+
- Never push the temporary tag created for changelog generation; only push
208+
the final tag after the PR is merged.
209+
- Keep the release PR strictly to version + changelog + documentation to
210+
maintain a clean history.
211+
- If multiple crates or files reference the version, confirm all of them are
212+
updated consistently.
213+
- For future convenience, parts of this document can be automated into a
214+
release script.

scripts/tag_release.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,23 @@ def _get_repo_url() -> str:
163163

164164

165165
def _github_anchor(changelog: Path, version: str) -> str:
166-
"""Build a GitHub-compatible anchor for the version heading."""
166+
"""Build a GitHub-compatible heading anchor (matches ``github-slugger``)."""
167167
try:
168168
for line in changelog.read_text(encoding="utf-8").splitlines():
169169
if line.startswith("## ") and (f"v{version}" in line or f"[{version}]" in line):
170-
heading = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", line)
171-
return heading[2:].strip().lower().replace(" ", "-").replace(".", "")
170+
heading = line.removeprefix("## ").strip()
171+
# Strip inline-link markup [text](url) → text
172+
heading = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", heading)
173+
# Strip reference-style brackets [text] → text
174+
heading = re.sub(r"\[([^\]]+)\]", r"\1", heading)
175+
heading = heading.lower()
176+
# Remove everything except letters, digits, spaces, hyphens
177+
heading = re.sub(r"[^a-z0-9\s-]", "", heading)
178+
# Replace whitespace runs with a single hyphen
179+
return re.sub(r"\s+", "-", heading)
172180
except OSError:
173181
pass
174-
return f"v{version.replace('.', '')}"
182+
return re.sub(r"[^a-z0-9-]", "", f"v{version}".lower())
175183

176184

177185
# ---------------------------------------------------------------------------
@@ -188,16 +196,14 @@ def create_tag(tag_version: str, *, force: bool = False) -> None:
188196
validate_semver(tag_version)
189197
version = parse_version(tag_version)
190198

191-
# Handle existing tag
192-
if _tag_exists(tag_version):
193-
if not force:
194-
print(f"{_YELLOW}Tag '{tag_version}' already exists.{_RESET}", file=sys.stderr)
195-
print(f"Use --force to recreate, or delete manually: git tag -d {tag_version}", file=sys.stderr)
196-
sys.exit(1)
197-
print(f"{_BLUE}Deleting existing tag '{tag_version}'...{_RESET}")
198-
_delete_tag(tag_version)
199+
# Check for existing tag (but don't delete yet — validate first)
200+
tag_existed = _tag_exists(tag_version)
201+
if tag_existed and not force:
202+
print(f"{_YELLOW}Tag '{tag_version}' already exists.{_RESET}", file=sys.stderr)
203+
print(f"Use --force to recreate, or delete manually: git tag -d {tag_version}", file=sys.stderr)
204+
sys.exit(1)
199205

200-
# Extract changelog section
206+
# Extract changelog section (before any mutation)
201207
changelog = find_changelog()
202208
section = extract_changelog_section(changelog, version)
203209
section_bytes = len(section.encode("utf-8"))
@@ -226,6 +232,11 @@ def create_tag(tag_version: str, *, force: bool = False) -> None:
226232
print("... (truncated for preview)")
227233
print("----------------------------------------")
228234

235+
# Delete existing tag only after all validation succeeds
236+
if tag_existed and force:
237+
print(f"{_BLUE}Deleting existing tag '{tag_version}'...{_RESET}")
238+
_delete_tag(tag_version)
239+
229240
# Create annotated tag
230241
label = "reference" if is_truncated else "full changelog"
231242
print(f"{_BLUE}Creating annotated tag '{tag_version}' with {label} content...{_RESET}")
@@ -235,7 +246,10 @@ def create_tag(tag_version: str, *, force: bool = False) -> None:
235246
print(f"{_GREEN}✓ Successfully created tag '{tag_version}'{_RESET}")
236247
print()
237248
print("Next steps:")
238-
print(f" 1. Push the tag: {_BLUE}git push origin {tag_version}{_RESET}")
249+
if force:
250+
print(f" 1. Force-push the tag: {_BLUE}git push --force origin {tag_version}{_RESET}")
251+
else:
252+
print(f" 1. Push the tag: {_BLUE}git push origin {tag_version}{_RESET}")
239253
print(f" 2. Create GitHub release: {_BLUE}gh release create {tag_version} --notes-from-tag{_RESET}")
240254
if is_truncated:
241255
print(f"\n{_YELLOW}Note: Tag annotation references CHANGELOG.md due to size (>125KB).{_RESET}")

scripts/tests/test_tag_release.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import tag_release
1111
from tag_release import (
1212
_GITHUB_TAG_ANNOTATION_LIMIT,
13+
_github_anchor,
1314
extract_changelog_section,
1415
find_changelog,
1516
parse_version,
@@ -144,6 +145,37 @@ def test_raises_for_empty_section(self, tmp_path: Path) -> None:
144145
extract_changelog_section(changelog, "1.0.0")
145146

146147

148+
# ---------------------------------------------------------------------------
149+
# GitHub anchor generation
150+
# ---------------------------------------------------------------------------
151+
152+
153+
class TestGitHubAnchor:
154+
"""Verify _github_anchor matches github-slugger output."""
155+
156+
def test_bracketed_heading(self, tmp_path: Path) -> None:
157+
"""Heading ``## [1.0.0] - 2025-01-01`` should strip brackets and dots."""
158+
changelog = tmp_path / "CHANGELOG.md"
159+
changelog.write_text(
160+
"# Changelog\n\n## [1.0.0] - 2025-01-01\n\n- Item\n",
161+
encoding="utf-8",
162+
)
163+
assert _github_anchor(changelog, "1.0.0") == "100---2025-01-01"
164+
165+
def test_plain_v_heading(self, tmp_path: Path) -> None:
166+
changelog = tmp_path / "CHANGELOG.md"
167+
changelog.write_text(
168+
"# Changelog\n\n## v0.2.0\n\n- Item\n",
169+
encoding="utf-8",
170+
)
171+
assert _github_anchor(changelog, "0.2.0") == "v020"
172+
173+
def test_fallback_when_not_found(self, tmp_path: Path) -> None:
174+
changelog = tmp_path / "CHANGELOG.md"
175+
changelog.write_text("# Changelog\n", encoding="utf-8")
176+
assert _github_anchor(changelog, "9.9.9") == "v999"
177+
178+
147179
# ---------------------------------------------------------------------------
148180
# Tag size limit handling
149181
# ---------------------------------------------------------------------------
@@ -258,3 +290,25 @@ def test_force_recreates_tag(
258290

259291
mock_delete.assert_called_once_with("v1.0.0")
260292
mock_git_input.assert_called_once()
293+
294+
@patch("tag_release._tag_exists", return_value=True)
295+
@patch("tag_release.find_changelog")
296+
@patch("tag_release.extract_changelog_section", side_effect=LookupError("not found"))
297+
@patch("tag_release._delete_tag")
298+
def test_force_does_not_delete_tag_if_changelog_fails(
299+
self,
300+
mock_delete: MagicMock,
301+
_mock_extract: MagicMock,
302+
mock_find: MagicMock,
303+
_mock_exists: MagicMock,
304+
tmp_path: Path,
305+
) -> None:
306+
"""Tag must not be deleted if changelog extraction fails."""
307+
changelog = tmp_path / "CHANGELOG.md"
308+
changelog.write_text("# Changelog\n", encoding="utf-8")
309+
mock_find.return_value = changelog
310+
311+
with pytest.raises(LookupError):
312+
tag_release.create_tag("v1.0.0", force=True)
313+
314+
mock_delete.assert_not_called()

0 commit comments

Comments
 (0)