1- # Tag-triggered release + PyPI publish for the Convert Python SDK
2- # (Story 5.1, qs-11 / qs-10).
3- #
4- # Trigger: a `v*` tag push only (e.g. v0.1.0, v0.1.0rc1). On any other event this
5- # workflow is a no-op (it has no other triggers).
6- #
7- # Flow: CI gate (reuses ci.yml) -> verify version matches the tag -> compile the
8- # towncrier changelog -> uv build -> publish to PyPI via OIDC Trusted Publishing
9- # (NO long-lived tokens) -> create a GitHub Release with the compiled changelog.
101name : Release
112
3+ # Triggered by a successful "CI" run on `main`, via `workflow_run`. Guarantees
4+ # the full CI gate passed before publishing. WORKFLOW-NAME COUPLING
5+ # (LOAD-BEARING): the value below must match ci.yml's `name:` EXACTLY ("CI").
6+ # Renaming either side silently breaks releases — `workflow_run` would never
7+ # fire. ci.yml carries the mirror comment.
128on :
13- push :
14- tags :
15- - " v*"
9+ workflow_run :
10+ workflows : ['CI']
11+ types : [completed]
12+ branches : [main]
1613
17- permissions :
18- contents : read
14+ # Serialize releases. If two pushes land on main in quick succession, the second
15+ # workflow_run waits for the first to finish — we must not have two publish runs
16+ # racing to upload the same version or two @semantic-release/github instances
17+ # racing to create the tag + Release.
18+ # `cancel-in-progress: false` because cancelling a mid-publish workflow can
19+ # leave a half-published state that needs manual cleanup.
20+ concurrency :
21+ group : release
22+ cancel-in-progress : false
1923
2024jobs :
21- # Reuse the full CI pipeline as the release gate. If any matrix cell, lint,
22- # type-check, bounds-check, or build fails, the downstream publish never runs.
23- ci-gate :
24- name : CI gate
25- uses : ./.github/workflows/ci.yml
26-
27- build :
28- name : Build release artifacts
25+ prepare :
26+ name : Compute version + build
2927 runs-on : ubuntu-latest
30- needs : ci-gate
28+ # Twin guards (both required):
29+ # (1) workflow_run.conclusion == 'success' → CI actually passed
30+ # (workflow_run fires on `completed` regardless of outcome).
31+ # (2) workflow_run.event == 'push' → the CI run that triggered us was on a
32+ # push to main, not a pull_request. FORK PRs fire `pull_request` events
33+ # and do NOT carry secret/OIDC access; triggering release on them would
34+ # fail or risk leaking into PR logs. DO NOT REMOVE THIS GUARD.
35+ if : >
36+ github.event.workflow_run.conclusion == 'success' &&
37+ github.event.workflow_run.event == 'push'
38+ permissions :
39+ contents : write # semantic-release --dry-run verifies push permission (no actual push)
3140 outputs :
32- version : ${{ steps.version.outputs.version }}
41+ released : ${{ steps.sr.outputs.released }}
42+ version : ${{ steps.sr.outputs.version }}
3343 steps :
3444 - uses : actions/checkout@v4
3545 with :
46+ # semantic-release analyzes all commits since the last tag; the
47+ # default shallow clone (depth 1) would blind it.
3648 fetch-depth : 0
49+ # Required so semantic-release can verify push permission.
50+ persist-credentials : true
51+ token : ${{ secrets.GITHUB_TOKEN }}
52+
53+ - uses : actions/setup-node@v4
54+ with :
55+ node-version : ' lts/*'
56+
57+ - name : Enable corepack (Yarn from the lockfile dialect)
58+ run : corepack enable
59+
60+ - name : Install Node release tooling
61+ run : yarn install --immutable
62+
3763 - name : Install uv
3864 uses : astral-sh/setup-uv@v5
3965 with :
4066 version : " 0.10.11"
4167 enable-cache : true
68+
4269 - name : Set up Python
4370 run : uv python install 3.13
44- - name : Sync dev environment
45- run : uv sync --group dev
46- - name : Verify tag matches the package version
47- id : version
48- shell : bash
71+
72+ # Dry-run: verifyReleaseCmd (release.config.mjs) exports version + writes
73+ # notes file.
74+ - name : semantic-release (dry-run → compute version + notes)
75+ id : sr
76+ env :
77+ GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
78+ RELEASE_NOTES_FILE : ${{ github.workspace }}/release-notes.md
79+ run : yarn release:dry-run
80+
81+ # Only when a release is due:
82+ - name : Stamp version into version.py (UNCOMMITTED) + build
83+ if : steps.sr.outputs.released == 'true'
4984 run : |
50- tag="${GITHUB_REF_NAME#v}"
51- pkg="$(uv run python -c 'from convert_sdk.version import __version__; print(__version__)')"
52- echo "tag=$tag pkg=$pkg"
53- if [ "$tag" != "$pkg" ]; then
54- echo "::error::tag ($tag) does not match package version ($pkg)"
55- exit 1
56- fi
57- echo "version=$tag" >> "$GITHUB_OUTPUT"
58- - name : Compile changelog from towncrier fragments
59- run : uv run towncrier build --yes --version "${{ steps.version.outputs.version }}"
60- - name : Extract release notes for this version
61- run : >-
62- uv run python scripts/extract_release_notes.py
63- --version "${{ steps.version.outputs.version }}"
64- --output release-notes.md
65- - name : Build wheel + sdist
66- run : uv build
67- - name : Upload build artifacts
85+ python - <<'PY'
86+ import pathlib, os, re
87+ v = os.environ["VERSION"]
88+ f = pathlib.Path("src/convert_sdk/version.py")
89+ f.write_text(re.sub(r'__version__ = "[^"]*"', f'__version__ = "{v}"', f.read_text()))
90+ PY
91+ uv build
92+ env :
93+ VERSION : ${{ steps.sr.outputs.version }}
94+
95+ - name : Upload dist + notes
96+ if : steps.sr.outputs.released == 'true'
6897 uses : actions/upload-artifact@v4
6998 with :
7099 name : release-dist
@@ -76,56 +105,59 @@ jobs:
76105 publish-pypi :
77106 name : Publish to PyPI (OIDC)
78107 runs-on : ubuntu-latest
79- needs : build
108+ needs : prepare
109+ if : needs.prepare.outputs.released == 'true'
110+ # OIDC Trusted Publishing — the whole job runs in the `pypi` environment so
111+ # the OIDC subject claim matches the registered Trusted Publisher
112+ # (owner: convertcom/python-sdk, workflow: release.yml, env: pypi).
113+ # This environment MUST have no required reviewers/wait timers, else the
114+ # publish job blocks. See RELEASE.md One-Time Setup.
80115 environment :
81116 name : pypi
82117 url : https://pypi.org/p/convert-python-sdk
83118 permissions :
84- # OIDC Trusted Publishing requires id-token: write. NO PyPI tokens are
85- # stored in repository secrets (qs-11 Critical Warning #1).
86- id-token : write
119+ id-token : write # OIDC Trusted Publishing — NO PyPI tokens in secrets
87120 steps :
88- - name : Download build artifacts
89- uses : actions/download-artifact@v4
121+ - uses : actions/download-artifact@v4
90122 with :
91123 name : release-dist
92- - name : Publish distributions to PyPI
93- # Trusted Publisher must be configured on pypi.org for this repo +
94- # workflow (one-time manual step — see docs/release-process .md). No
95- # password/token input: the action exchanges the OIDC token.
96- uses : pypa/gh-action-pypi-publish@release/v1
124+
125+ # Trusted Publisher must be configured on pypi.org for this repo +
126+ # workflow + environment (one-time manual step — see RELEASE .md).
127+ # No password/token input: the action exchanges the OIDC token.
128+ - uses : pypa/gh-action-pypi-publish@release/v1
97129 with :
98130 packages-dir : dist
99131
100- github- release :
101- name : Create GitHub Release
132+ release :
133+ name : Tag + GitHub Release (semantic-release)
102134 runs-on : ubuntu-latest
103- needs : [build, publish-pypi]
135+ # publish-before-release: skipped if PyPI upload failed (needs both jobs).
136+ needs : [prepare, publish-pypi]
137+ if : needs.prepare.outputs.released == 'true'
104138 permissions :
105- contents : write
139+ contents : write # push vX.Y.Z tag + create the GitHub Release
106140 steps :
107- - name : Download build artifacts
108- uses : actions/download-artifact@v4
141+ - uses : actions/checkout@v4
109142 with :
110- name : release-dist
111- - name : Determine prerelease flag
112- id : prerelease
113- shell : bash
114- run : |
115- tag="${GITHUB_REF_NAME}"
116- # Mark a/b/rc/dev/alpha/beta tags as prereleases on the GitHub Release.
117- if echo "$tag" | grep -Eiq '(a|b|rc|dev|alpha|beta)[0-9]*$'; then
118- echo "flag=--prerelease" >> "$GITHUB_OUTPUT"
119- else
120- echo "flag=" >> "$GITHUB_OUTPUT"
121- fi
122- - name : Create the GitHub Release with the compiled changelog
143+ fetch-depth : 0
144+ persist-credentials : true
145+ token : ${{ secrets.GITHUB_TOKEN }}
146+
147+ - uses : actions/setup-node@v4
148+ with :
149+ node-version : ' lts/*'
150+
151+ - name : Enable corepack
152+ run : corepack enable
153+
154+ - name : Install Node release tooling
155+ run : yarn install --immutable
156+
157+ # Real run: re-derives the SAME version (deterministic — no commit landed
158+ # since prepare), prepareCmd re-stamps version.py (harmless),
159+ # @semantic-release/github pushes the tag + creates the Release.
160+ - name : semantic-release (tag + GitHub Release)
123161 env :
124- GH_TOKEN : ${{ github.token }}
125- run : >-
126- gh release create "${GITHUB_REF_NAME}"
127- --repo "${GITHUB_REPOSITORY}"
128- --title "${GITHUB_REF_NAME}"
129- --notes-file release-notes.md
130- ${{ steps.prerelease.outputs.flag }}
131- dist/*
162+ GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
163+ run : yarn release
0 commit comments