Skip to content

Commit 21699ce

Browse files
committed
release: automate GitHub Releases
1 parent b95179a commit 21699ce

4 files changed

Lines changed: 86 additions & 6 deletions

File tree

.github/workflows/release.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,61 @@ jobs:
197197

198198
- name: Publish to PyPI
199199
uses: pypa/gh-action-pypi-publish@release/v1
200+
201+
publish-github-release:
202+
name: Publish v${{ inputs.release_version }} GitHub Release
203+
needs: publish-pypi
204+
if: inputs.publish_pypi == 'true' && github.ref_type == 'tag'
205+
runs-on: ubuntu-latest
206+
permissions:
207+
contents: write
208+
steps:
209+
- name: Check out release notes
210+
uses: actions/checkout@v6
211+
with:
212+
persist-credentials: false
213+
214+
- name: Download exact distributions
215+
uses: actions/download-artifact@v8
216+
with:
217+
name: tradingcodex-${{ inputs.release_version }}
218+
path: dist/
219+
220+
- name: Extract matching changelog section
221+
env:
222+
RELEASE_VERSION: ${{ inputs.release_version }}
223+
run: |
224+
python - <<'PY'
225+
import os
226+
import re
227+
from pathlib import Path
228+
229+
version = re.escape(os.environ["RELEASE_VERSION"])
230+
changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
231+
match = re.search(
232+
rf"^## {version} - .*$\n.*?(?=^## |\Z)",
233+
changelog,
234+
flags=re.MULTILINE | re.DOTALL,
235+
)
236+
if match is None:
237+
raise SystemExit(f"missing CHANGELOG.md section for {os.environ['RELEASE_VERSION']}")
238+
Path("release-notes.md").write_text(match.group(0).strip() + "\n", encoding="utf-8")
239+
PY
240+
241+
- name: Create or update GitHub Release
242+
env:
243+
GH_TOKEN: ${{ github.token }}
244+
RELEASE_VERSION: ${{ inputs.release_version }}
245+
run: |
246+
tag="v${RELEASE_VERSION}"
247+
title="TradingCodex ${RELEASE_VERSION}"
248+
assets=(
249+
"dist/tradingcodex-${RELEASE_VERSION}-py3-none-any.whl"
250+
"dist/tradingcodex-${RELEASE_VERSION}.tar.gz"
251+
)
252+
if gh release view "$tag" >/dev/null 2>&1; then
253+
gh release edit "$tag" --title "$title" --notes-file release-notes.md
254+
gh release upload "$tag" "${assets[@]}" --clobber
255+
else
256+
gh release create "$tag" "${assets[@]}" --verify-tag --title "$title" --notes-file release-notes.md
257+
fi

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Create or refresh the GitHub Release from the exact verified distributions
6+
and matching changelog section only after protected PyPI publication succeeds.
7+
58
## 1.2.0 - 2026-07-20
69

710
- Refresh the manual GitHub Pages workflow to the current Node 24 action

docs/deployment.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,12 @@ requests and pushes still receive the normal source-quality checks.
204204
The release workflow has one build/verification job followed by the required
205205
Python 3.11-3.14 Linux, Intel-macOS, and native-Windows calculation-runtime
206206
matrix. When publication is requested, one protected PyPI job downloads that
207-
exact artifact and uploads it; no job rebuilds the distribution after
208-
verification. These distribution and platform jobs run only after an explicit
209-
`Manual Release` dispatch, never because a branch, tag, development commit, or
210-
documentation change was pushed.
207+
exact artifact and uploads it. After PyPI succeeds, a GitHub Release job
208+
downloads the same artifact, uses the matching `CHANGELOG.md` section as its
209+
notes, and creates or refreshes the tag's wheel and source-distribution assets.
210+
No job rebuilds the distribution after verification. These distribution and
211+
platform jobs run only after an explicit `Manual Release` dispatch, never
212+
because a branch, tag, development commit, or documentation change was pushed.
211213

212214
## User Guide Pages
213215

@@ -246,7 +248,8 @@ The guide for this repository is published at
246248
5. In GitHub Actions, run `Manual Release` from that tag with the exact
247249
`release_version`. Use `publish_pypi=true` for an approved publication; use
248250
the optional `publish_pypi=false` rehearsal when release risk warrants an
249-
additional hosted build.
251+
additional hosted build. A successful approved publication also creates or
252+
refreshes the matching GitHub Release from the verified artifacts.
250253

251254
Pushing a branch or tag does not publish by itself.
252255

tests/test_release_contract.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ def test_release_publish_is_tag_bound_and_reuses_one_verified_build() -> None:
656656
assert "parse_wheel_filename" in distribution["run"]
657657
assert "actual_artifacts != expected_artifacts" in distribution["run"]
658658
assert "unexpected distribution files" in distribution["run"]
659-
assert set(jobs) == {"build", "calculation-runtime-matrix", "publish-pypi"}
659+
assert set(jobs) == {"build", "calculation-runtime-matrix", "publish-pypi", "publish-github-release"}
660660
runtime_matrix = jobs["calculation-runtime-matrix"]
661661
assert runtime_matrix["needs"] == "build"
662662
assert runtime_matrix["strategy"]["fail-fast"] is False
@@ -685,6 +685,22 @@ def test_release_publish_is_tag_bound_and_reuses_one_verified_build() -> None:
685685
assert any("download-artifact" in step.get("uses", "") for step in jobs["publish-pypi"]["steps"])
686686
assert not any("python -m build" in step.get("run", "") for step in jobs["publish-pypi"]["steps"])
687687

688+
github_release = jobs["publish-github-release"]
689+
assert github_release["needs"] == "publish-pypi"
690+
assert github_release["if"] == "inputs.publish_pypi == 'true' && github.ref_type == 'tag'"
691+
assert github_release["permissions"] == {"contents": "write"}
692+
release_job_steps = "\n".join(str(step.get("run", "")) for step in github_release["steps"])
693+
assert any("actions/checkout@v6" in step.get("uses", "") for step in github_release["steps"])
694+
assert any("download-artifact" in step.get("uses", "") for step in github_release["steps"])
695+
assert "CHANGELOG.md" in release_job_steps
696+
assert "release-notes.md" in release_job_steps
697+
assert "gh release create" in release_job_steps
698+
assert "gh release edit" in release_job_steps
699+
assert "gh release upload" in release_job_steps
700+
assert '"${assets[@]}" --clobber' in release_job_steps
701+
assert "python -m build" not in release_job_steps
702+
assert "gh-action-pypi-publish" not in release_job_steps
703+
688704

689705
def test_normal_github_uploads_do_not_build_or_deploy_release_artifacts() -> None:
690706
ci = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8"))

0 commit comments

Comments
 (0)