Skip to content

Commit 6239b48

Browse files
author
Brad Kinnard
committed
ci: add release-notes promotion workflow
On release: published, the workflow promotes the CHANGELOG.md [Unreleased] block under a [<tag>] - <today> heading and opens a PR back to main. The promotion script also no-ops when [Unreleased] is empty or when the developer already cut the heading by hand, so the workflow is safe to keep on for every release without producing empty or duplicate PRs.
1 parent 81890be commit 6239b48

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Release notes
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
contents: write
9+
pull-requests: write
10+
11+
jobs:
12+
promote-unreleased:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
ref: main
18+
fetch-depth: 0
19+
20+
- name: Promote [Unreleased] to released version heading
21+
id: promote
22+
shell: bash
23+
env:
24+
TAG: ${{ github.event.release.tag_name }}
25+
run: |
26+
set -euo pipefail
27+
version="${TAG#v}"
28+
today=$(date -u +%Y-%m-%d)
29+
python3 - "$version" "$today" <<'PY'
30+
import re
31+
import sys
32+
from pathlib import Path
33+
34+
version, today = sys.argv[1], sys.argv[2]
35+
path = Path("CHANGELOG.md")
36+
text = path.read_text(encoding="utf-8")
37+
38+
# Locate the [Unreleased] block: its content runs until the next ## heading.
39+
match = re.search(r"^##\s*\[Unreleased\]\s*\n", text, re.MULTILINE)
40+
if not match:
41+
print("CHANGELOG.md has no [Unreleased] heading; nothing to promote.")
42+
sys.exit(0)
43+
44+
start = match.end()
45+
next_heading = re.search(r"^##\s*\[", text[start:], re.MULTILINE)
46+
end = start + next_heading.start() if next_heading else len(text)
47+
body = text[start:end].strip()
48+
49+
if not body:
50+
print("[Unreleased] is empty; nothing to promote.")
51+
# Signal an empty promotion to the next step.
52+
Path("PROMOTED.flag").write_text("empty", encoding="utf-8")
53+
sys.exit(0)
54+
55+
# If a heading for the released version already exists, the developer
56+
# promoted by hand; treat as a no-op to avoid duplicating entries.
57+
if re.search(rf"^##\s*\[{re.escape(version)}\]", text, re.MULTILINE):
58+
print(f"[{version}] heading already present; not promoting.")
59+
Path("PROMOTED.flag").write_text("already", encoding="utf-8")
60+
sys.exit(0)
61+
62+
new_block = (
63+
f"## [Unreleased]\n\n"
64+
f"## [{version}] - {today}\n\n{body}\n\n"
65+
)
66+
updated = text[:match.start()] + new_block + text[end:].lstrip()
67+
path.write_text(updated, encoding="utf-8")
68+
Path("PROMOTED.flag").write_text("promoted", encoding="utf-8")
69+
print(f"Promoted [Unreleased] -> [{version}] - {today}")
70+
PY
71+
if [ -f PROMOTED.flag ]; then
72+
echo "result=$(cat PROMOTED.flag)" >> "$GITHUB_OUTPUT"
73+
rm PROMOTED.flag
74+
else
75+
echo "result=skipped" >> "$GITHUB_OUTPUT"
76+
fi
77+
78+
- name: Open PR with the promoted CHANGELOG
79+
if: steps.promote.outputs.result == 'promoted'
80+
uses: peter-evans/create-pull-request@v6
81+
with:
82+
commit-message: |
83+
docs(changelog): promote [Unreleased] to [${{ github.event.release.tag_name }}]
84+
title: "docs(changelog): promote [Unreleased] to [${{ github.event.release.tag_name }}]"
85+
body: |
86+
Automated promotion triggered by the `${{ github.event.release.tag_name }}` GitHub release.
87+
Moves the `[Unreleased]` block under a new `[${{ github.event.release.tag_name }}]` heading dated today.
88+
branch: changelog/promote-${{ github.event.release.tag_name }}
89+
base: main
90+
labels: docs, automation

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Added
2222

23+
- `.github/workflows/release-notes.yml`: when a GitHub Release is published, the workflow inspects `CHANGELOG.md`'s `[Unreleased]` block and, if it has un-promoted content for the tag, opens a PR against `main` that moves the block under a `[<tag>] - <today>` heading. No-op when `[Unreleased]` is empty or when the developer already promoted by hand.
2324
- `tests/test_agent_prompts_smoke.py`: snapshot tests that pin SHA-256 digests of each rendered prompt (six combinations: critique/graph x claude/codex/cursor) against `tests/fixtures/valid_basic.md`. Catches accidental edits to prompt scaffolding without requiring an oracle for prompt quality.
2425
- Published JSON Schema (Draft 2020-12) files for the agent IO contracts: `src/skillcheck/schemas/critique-v1.json` and `src/skillcheck/schemas/graph-v1.json`. Both ship with the wheel. `skillcheck.agents.SCHEMAS` maps `"critique-v1"` and `"graph-v1"` to the on-disk paths so callers can validate agent responses before invoking `--ingest-critique` or `--ingest-graph`. The schemas mirror the parser-enforced required fields, severity enum, kind enums, and score ranges; `test_published_schemas.py` guards drift between the schema files and the parsers.
2526
- `[frontmatter] reserved_words` config key in `skillcheck.toml`. Replaces the default `('anthropic', 'claude')` reserved-word list that powers `frontmatter.name.reserved-word`. An empty array reverts to the defaults so orgs cannot silently disable the check. Example: `reserved_words = ["acme", "internal"]`.

0 commit comments

Comments
 (0)