@@ -2,18 +2,18 @@ name: Publish to PyPI on version change
22
33on :
44 push :
5- branches : [ main ]
5+ branches : [ main , dev ]
66
77permissions :
8- contents : read
8+ contents : write
99 id-token : write
1010
1111jobs :
1212 build-and-publish :
1313 runs-on : ubuntu-latest
1414
1515 steps :
16- - name : Check out repo (full history for diff)
16+ - name : Check out repo (full history for diff & tags )
1717 uses : actions/checkout@v4
1818 with :
1919 fetch-depth : 0
@@ -32,48 +32,40 @@ jobs:
3232 run : |
3333 set -euo pipefail
3434 python - << 'PY'
35- import io, os, subprocess, sys
35+ import io, os, subprocess, sys, re, textwrap, datetime
3636 try:
3737 import tomllib # py311+
38- except Exception:
39- print("tomllib missing", file=sys.stderr)
40- sys.exit(1)
38+ except Exception as e:
39+ print("tomllib missing", file=sys.stderr); raise
4140
4241 def load_version_from_bytes(b: bytes):
4342 return tomllib.load(io.BytesIO(b))["project"]["version"]
4443
45- # current version
46- with open("pyproject.toml", "rb") as f:
44+ # Current version (from workspace)
45+ with open("pyproject.toml","rb") as f:
4746 current = load_version_from_bytes(f.read())
4847
49- # previous version from the commit before this push (GitHub provides it )
48+ # Previous version ( from commit before this push)
5049 before = os.environ.get("BEFORE_SHA") or ""
5150 prev = None
5251 if before and before != "0000000000000000000000000000000000000000":
5352 try:
54- blob = subprocess.check_output(["git", "show", f"{before}:pyproject.toml"])
53+ blob = subprocess.check_output(["git","show",f"{before}:pyproject.toml"])
5554 prev = load_version_from_bytes(blob)
5655 except subprocess.CalledProcessError:
57- prev = None # file may not have existed
56+ prev = None
5857
5958 changed = (prev is None) or (current != prev)
6059
61- with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
62- fh.write(f"current={current}\n")
63- fh.write(f"previous={prev or ''}\n")
64- fh.write(f"changed={'true' if changed else 'false'}\n")
65-
66- print(f"Current version: {current}")
67- print(f"Previous version: {prev}")
68- print(f"Changed: {changed}")
60+ # Stash values for later steps
61+ with open(os.environ["GITHUB_OUTPUT"],"a") as out:
62+ out.write(f"current={current}\n")
63+ out.write(f"previous={prev or ''}\n")
64+ out.write(f"changed={'true' if changed else 'false'}\n")
65+ print(f"Current version: {current}\nPrevious version: {prev}\nChanged: {changed}")
6966 PY
7067
71- - name : Show decision
72- run : |
73- echo "current=${{ steps.ver.outputs.current }}"
74- echo "previous=${{ steps.ver.outputs.previous }}"
75- echo "changed=${{ steps.ver.outputs.changed }}"
76-
68+ # ---------- Build & Publish to PyPI (only if version changed) ----------
7769 - name : Build sdist + wheel
7870 if : steps.ver.outputs.changed == 'true'
7971 run : |
@@ -85,4 +77,123 @@ jobs:
8577 if : steps.ver.outputs.changed == 'true'
8678 uses : pypa/gh-action-pypi-publish@release/v1
8779 with :
88- skip-existing : true
80+ skip-existing : true
81+
82+ # ---------- Prepare CHANGELOG update & release notes ----------
83+ - name : Prepare CHANGELOG update & release notes
84+ id : cl
85+ if : steps.ver.outputs.changed == 'true'
86+ env :
87+ VERSION : ${{ steps.ver.outputs.current }}
88+ PREV_VERSION : ${{ steps.ver.outputs.previous }}
89+ REPO : ${{ github.repository }}
90+ run : |
91+ set -euo pipefail
92+ python - << 'PY'
93+ import os, re, sys, datetime, pathlib
94+
95+ repo = os.environ["REPO"] # e.g., org/repo
96+ version = os.environ["VERSION"] # e.g., 0.1.2
97+ prev = os.environ.get("PREV_VERSION") or "" # e.g., 0.1.1 or ""
98+
99+ changelog_path = pathlib.Path("CHANGELOG.md")
100+ if not changelog_path.exists():
101+ # Some repos use Changelog.md
102+ alt = pathlib.Path("Changelog.md")
103+ if alt.exists():
104+ changelog_path = alt
105+ else:
106+ print("No CHANGELOG.md found", file=sys.stderr)
107+ sys.exit(1)
108+
109+ text = changelog_path.read_text(encoding="utf-8")
110+
111+ # Grab the [Unreleased] block (non-greedy until the next version header or EOF)
112+ unreleased_re = re.compile(r"^## \\[Unreleased\\]\\s*(.*?)\\s*(?=^## \\[|\\Z)", re.S | re.M)
113+ m = unreleased_re.search(text)
114+ unreleased_body = (m.group(1).strip() if m else "").strip()
115+ if not unreleased_body:
116+ unreleased_body = "_No notable changes._"
117+
118+ # Build the new version section
119+ today = datetime.date.today().isoformat() # YYYY-MM-DD
120+ new_section = f"## [{version}] - {today}\\n{unreleased_body}\\n\\n"
121+
122+ # Replace the Unreleased section with an empty placeholder
123+ if m:
124+ start, end = m.span()
125+ prefix = text[:m.start()]
126+ suffix = text[m.end():]
127+ # Minimal placeholder keeps it tidy
128+ unreleased_placeholder = "## [Unreleased]\\n\\n"
129+ text = prefix + unreleased_placeholder + suffix
130+ else:
131+ # If there was no [Unreleased], put one at the top under the main title
132+ text = text.replace("## Changelog", "## Changelog\\n\\n## [Unreleased]\\n")
133+
134+ # Insert the new version section right after [Unreleased]
135+ insert_point = text.find("## [Unreleased]")
136+ if insert_point != -1:
137+ # Find end of that line to insert after
138+ insert_point = text.find("\n", insert_point)
139+ if insert_point == -1:
140+ insert_point = len(text)
141+ text = text[:insert_point+1] + new_section + text[insert_point+1:]
142+ else:
143+ # Fallback: prepend
144+ text = new_section + text
145+
146+ # ----- Update link reference section at the bottom -----
147+ # Ensure we have lines for [Unreleased] and the new version comparing prev..current
148+ # Remove any existing [Unreleased]: line to replace cleanly
149+ lines = text.rstrip() .splitlines()
150+ # Find if there's an existing reference block (last contiguous lines that look like "[x]: url")
151+ # We'll just rebuild/append ours safely.
152+ def set_ref(name, url):
153+ ref = f"[{name}]: {url}"
154+ # replace existing or append
155+ for i, ln in enumerate(lines):
156+ if ln.startswith(f"[{name}]"):
157+ lines[i] = ref
158+ return
159+ lines.append(ref)
160+
161+ base = f"https://github.com/{repo}"
162+ # previous version for compare link; if unknown, compare from first commit
163+ prev_tag = f"v{prev}" if prev else "v0.0.0"
164+ set_ref("Unreleased", f"{base}/compare/v{version}...HEAD")
165+ set_ref(version, f"{base}/compare/{prev_tag}...v{version}")
166+
167+ new_text = "\n".join(lines) + "\n"
168+
169+ # Write a temp file for release notes (just the body we moved)
170+ pathlib.Path("RELEASE_NOTES.md").write_text(unreleased_body + "\n", encoding="utf-8")
171+ # And write the updated changelog to a temp file (we only commit after publish)
172+ pathlib.Path("CHANGELOG.updated.md").write_text(new_text, encoding="utf-8")
173+ PY
174+
175+ # ---------- Commit CHANGELOG, tag, and create GitHub Release ----------
176+ - name : Commit updated CHANGELOG
177+ if : steps.ver.outputs.changed == 'true'
178+ run : |
179+ mv CHANGELOG.updated.md CHANGELOG.md
180+ git config user.name "github-actions[bot]"
181+ git config user.email "github-actions[bot]@users.noreply.github.com"
182+ git add CHANGELOG.md
183+ git commit -m "chore(release): v${{ steps.ver.outputs.current }} [skip ci]"
184+ git push
185+
186+ - name : Create and push tag
187+ if : steps.ver.outputs.changed == 'true'
188+ run : |
189+ git tag -a "v${{ steps.ver.outputs.current }}" -m "v${{ steps.ver.outputs.current }}"
190+ git push origin "v${{ steps.ver.outputs.current }}"
191+
192+ - name : Create GitHub Release with notes from CHANGELOG
193+ if : steps.ver.outputs.changed == 'true'
194+ env :
195+ GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
196+ run : |
197+ gh release create "v${{ steps.ver.outputs.current }}" \
198+ --title "v${{ steps.ver.outputs.current }}" \
199+ --notes-file RELEASE_NOTES.md
0 commit comments