Skip to content

Commit 3fe22e6

Browse files
abbaseyaclaude
andcommitted
chore: migrate release pipeline to semantic-release (ruby-sdk parity)
Replaces the manual version-bump + tag-trigger + towncrier-committed-changelog release model with the commit-driven, zero-touch semantic-release model that mirrors ruby-sdk: workflow_run-triggered on a successful CI run on main, version computed from Conventional Commits, stamped into version.py at build time (uncommitted), published to PyPI via OIDC Trusted Publishing (pypa), then only the vX.Y.Z tag + a GitHub Release (notes = changelog) created after a successful upload. No commit is ever pushed to main. Implements qs-14 phases 1-6: - Dev-only Node release tooling: package.json, release.config.mjs, .yarnrc.yml, generated yarn.lock (Berry). prepareCmd uses `node -e` (present in both jobs) to stamp version.py, avoiding a runner python-symlink dependency. - release.yml rewritten to 3 jobs (prepare -> publish-pypi -> release) with the workflow_run trigger on ['CI'], twin if-guard, fork-PR safeguard, OIDC publish. - ci.yml: removed the towncrier changelog job, added a PR-title Conventional Commits job, removed workflow_call, carried the load-bearing CI-name comment. - Removed committed-changelog machinery: [tool.towncrier], towncrier dev-dep + mypy override, changes/, CHANGELOG.md, scripts/extract_release_notes.py, the verify_release.py towncrier gate. Added Changelog URL to [project.urls]. - version.py -> 0.0.0 dev placeholder (build stamps the real version). - Added root RELEASE.md (ruby-sdk parity, adapted to PyPI/semantic-release/pypa). - Deleted in-repo docs/ and swept every reference (README badges + wiki links, generalized docstrings, deleted test_docs_samples.py). Verification: yarn install --immutable OK; yarn release:dry-run OK; coverage 96.63% project / 97% evaluation (floors 85%/95%); git grep docs/ and towncrier both empty; built wheel/sdist exclude Node artifacts + docs/changes/CHANGELOG. Spec: ai-driven-product-dev qs-14-semantic-release-migration.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bc76b64 commit 3fe22e6

47 files changed

Lines changed: 4760 additions & 2827 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1-
# CI pipeline for the Convert Python SDK (Story 5.1, qs-02 / qs-03 / qs-09 / qs-10).
1+
# CI pipeline for the Convert Python SDK (Story 5.1, qs-02 / qs-03 / qs-09).
22
#
33
# Job graph (fail-fast lint/type-check before the matrix):
4+
# pr-title (PR-only, independent)
45
# lint -> type-check -> test (15-cell matrix) -> build
56
# \-> bounds-check (lower/upper)
6-
# changelog (PR-only, independent)
77
#
8-
# All dependency resolution uses `uv` (never `pip install -e .`). The workflow is
9-
# `workflow_call`-able so release.yml can reuse it verbatim as the release gate.
8+
# All dependency resolution uses `uv` (never `pip install -e .`).
9+
#
10+
# WORKFLOW-NAME COUPLING (LOAD-BEARING): the `name: CI` below must match
11+
# release.yml's `workflow_run.workflows` value EXACTLY ("CI"). Renaming either
12+
# side silently breaks releases — `workflow_run` would never fire. release.yml
13+
# carries the mirror comment.
1014
name: CI
1115

1216
on:
1317
push:
1418
branches: [main]
1519
pull_request:
16-
workflow_call: {}
1720

1821
# Cancel superseded runs on the same ref to keep the matrix under the merge ceiling.
1922
concurrency:
@@ -28,6 +31,25 @@ env:
2831
UV_VERSION: "0.10.11"
2932

3033
jobs:
34+
pr-title:
35+
name: PR title (Conventional Commits)
36+
runs-on: ubuntu-latest
37+
if: github.event_name == 'pull_request'
38+
steps:
39+
- name: Validate PR title
40+
env:
41+
PR_TITLE: ${{ github.event.pull_request.title }}
42+
run: |
43+
# Dependency-free Conventional Commits check (squash-merge-only repo:
44+
# the squash commit subject is the PR title).
45+
if ! echo "$PR_TITLE" | grep -qE '^(feat|fix|docs|test|refactor|perf|build|ci|chore)(\(.+\))?!?: .+'; then
46+
echo "::error::PR title must follow Conventional Commits: '$PR_TITLE'"
47+
echo "Expected: <type>(<scope>)?!?: <description>"
48+
echo "Allowed types: feat, fix, docs, test, refactor, perf, build, ci, chore"
49+
exit 1
50+
fi
51+
echo "PR title OK: $PR_TITLE"
52+
3153
lint:
3254
name: Ruff lint
3355
runs-on: ubuntu-latest
@@ -138,34 +160,6 @@ jobs:
138160
run: >-
139161
uv run pytest -p no:cacheprovider --ignore=tests/parity
140162
141-
changelog:
142-
name: changelog fragment
143-
runs-on: ubuntu-latest
144-
# Only meaningful on PRs (push to main happens after merge, when the fragment
145-
# has already been validated). Non-blocking on release/tag refs.
146-
if: github.event_name == 'pull_request'
147-
steps:
148-
- uses: actions/checkout@v4
149-
with:
150-
fetch-depth: 0
151-
- name: Install uv
152-
uses: astral-sh/setup-uv@v5
153-
with:
154-
version: ${{ env.UV_VERSION }}
155-
enable-cache: true
156-
- name: Set up Python
157-
run: uv python install 3.13
158-
- name: Sync dev environment
159-
run: uv sync --group dev
160-
# Auto-generated serving-config stub PRs (branch api-serving-pyi-update-*,
161-
# opened by the backend update-api-clients-serving.yml workflow) carry no
162-
# changelog fragment by design — matching ruby-sdk/php-sdk, which have no
163-
# changelog gate on their equivalent auto-PRs. Guard the STEP (not the job)
164-
# so the job still reports success if this check is required by branch protection.
165-
- name: Require a changelog fragment (towncrier)
166-
if: ${{ !startsWith(github.head_ref, 'api-serving-pyi-update-') }}
167-
run: uv run towncrier check --compare-with origin/${{ github.base_ref }}
168-
169163
build:
170164
name: build (wheel + sdist)
171165
runs-on: ubuntu-latest

‎.github/workflows/release.yml‎

Lines changed: 117 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,99 @@
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.
101
name: 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.
128
on:
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

2024
jobs:
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

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,9 @@ scripts/verify_staging_transaction.py
3535

3636
# Demo harness local credentials (never committed)
3737
demo/.env
38+
39+
# Node release tooling (dev-only — never ships in the wheel/sdist)
40+
# package.json / release.config.mjs / yarn.lock / .yarnrc.yml ARE committed;
41+
# node_modules and the Yarn install-state cache are not (ruby-sdk parity).
42+
node_modules/
43+
.yarn/

‎.yarnrc.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
nodeLinker: node-modules

‎CHANGELOG.md‎

Lines changed: 0 additions & 10 deletions
This file was deleted.

0 commit comments

Comments
 (0)