Skip to content

Commit fb2f85c

Browse files
authored
Merge pull request #34 from VapiAI/codex/release-pipeline-parity
Publish tested Python artifacts through PyPI trusted publishing
2 parents 6090072 + 0927244 commit fb2f85c

5 files changed

Lines changed: 193 additions & 34 deletions

File tree

‎.fernignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,7 @@ tests/custom/test_structured_output_run.py
1414
tests/custom/test_vapi_managed_model.py
1515
tests/custom/test_tool_variants.py
1616
tests/custom/test_model_deprecation_notice.py
17+
18+
.github/scripts/verify-release-commit.sh
19+
.github/scripts/validate-release.py
20+
.github/scripts/test_release_validation.py
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Exercise release guards with actual Git histories, without registry access."""
2+
3+
import os
4+
from pathlib import Path
5+
import subprocess
6+
import sys
7+
import tempfile
8+
import unittest
9+
10+
SCRIPTS = Path(__file__).resolve().parent
11+
12+
13+
class ReleaseValidationTests(unittest.TestCase):
14+
def setUp(self):
15+
self.temp = tempfile.TemporaryDirectory(prefix="sdk-release-")
16+
self.addCleanup(self.temp.cleanup)
17+
self.cwd = Path(self.temp.name)
18+
self.env = {
19+
**os.environ,
20+
"GIT_AUTHOR_NAME": "Release test",
21+
"GIT_AUTHOR_EMAIL": "test@example.com",
22+
"GIT_COMMITTER_NAME": "Release test",
23+
"GIT_COMMITTER_EMAIL": "test@example.com",
24+
}
25+
self.git("init", "--initial-branch=main")
26+
self.git("commit", "--allow-empty", "-m", "Approved release")
27+
self.approved = self.git("rev-parse", "HEAD")
28+
self.git("update-ref", "refs/remotes/origin/main", self.approved)
29+
(self.cwd / "pyproject.toml").write_text('[tool.poetry]\nversion = "3.0.0"\n')
30+
self.env.update(GITHUB_REF="refs/tags/v3.0.0", GITHUB_REF_NAME="v3.0.0", GITHUB_SHA=self.approved)
31+
32+
def git(self, *args):
33+
return subprocess.run(["git", *args], cwd=self.cwd, env=self.env, check=True, capture_output=True, text=True).stdout.strip()
34+
35+
def guard(self):
36+
return subprocess.run(["bash", str(SCRIPTS / "verify-release-commit.sh")], cwd=self.cwd, env=self.env, capture_output=True, text=True)
37+
38+
def test_approved_commit_and_ancestor_are_allowed(self):
39+
self.assertEqual(self.guard().returncode, 0)
40+
self.git("commit", "--allow-empty", "-m", "Newer approved commit")
41+
self.git("update-ref", "refs/remotes/origin/main", self.git("rev-parse", "HEAD"))
42+
self.git("checkout", "--detach", self.approved)
43+
self.assertEqual(self.guard().returncode, 0)
44+
45+
def test_annotated_tag_is_resolved_to_commit(self):
46+
self.git("tag", "-a", "v3.0.0", "-m", "Release")
47+
self.env["GITHUB_SHA"] = self.git("rev-parse", "v3.0.0")
48+
self.assertEqual(self.guard().returncode, 0)
49+
50+
def test_unmerged_commit_is_rejected(self):
51+
self.git("checkout", "-b", "unapproved")
52+
self.git("commit", "--allow-empty", "-m", "Unreviewed change")
53+
self.env["GITHUB_SHA"] = self.git("rev-parse", "HEAD")
54+
result = self.guard()
55+
self.assertNotEqual(result.returncode, 0)
56+
self.assertIn("must already be merged", result.stdout)
57+
58+
def test_wrong_checkout_is_rejected(self):
59+
self.git("commit", "--allow-empty", "-m", "Different checkout")
60+
result = self.guard()
61+
self.assertNotEqual(result.returncode, 0)
62+
self.assertIn("does not match", result.stdout)
63+
64+
def test_missing_main_or_event_commit_is_rejected(self):
65+
self.git("update-ref", "-d", "refs/remotes/origin/main")
66+
self.assertNotEqual(self.guard().returncode, 0)
67+
del self.env["GITHUB_SHA"]
68+
self.assertNotEqual(self.guard().returncode, 0)
69+
70+
def test_package_version_and_ref(self):
71+
for tag in ("3.0.0", "v3.0.0"):
72+
self.env.update(GITHUB_REF=f"refs/tags/{tag}", GITHUB_REF_NAME=tag)
73+
result = subprocess.run([sys.executable, str(SCRIPTS / "validate-release.py")], cwd=self.cwd, env=self.env, capture_output=True, text=True)
74+
self.assertEqual(result.returncode, 0, result.stderr)
75+
for ref, tag in (("refs/heads/main", "main"), ("refs/heads/refs/tags/v3.0.0", "refs/tags/v3.0.0"), ("refs/tags/v3.0.1", "v3.0.1"), ("refs/tags/vv3.0.0", "vv3.0.0"), ("refs/tags/v3.0.0", "3.0.0"), ("", "")):
76+
with self.subTest(ref=ref, tag=tag):
77+
self.env.update(GITHUB_REF=ref, GITHUB_REF_NAME=tag)
78+
if not ref.startswith("refs/tags/"):
79+
self.assertNotEqual(self.guard().returncode, 0)
80+
result = subprocess.run([sys.executable, str(SCRIPTS / "validate-release.py")], cwd=self.cwd, env=self.env, capture_output=True, text=True)
81+
self.assertNotEqual(result.returncode, 0)
82+
83+
84+
if __name__ == "__main__":
85+
unittest.main()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Validate release refs before running package build code (Python 3.11+)."""
2+
3+
import os
4+
from pathlib import Path
5+
import sys
6+
import tomllib
7+
8+
9+
def main() -> None:
10+
tag = os.environ.get("GITHUB_REF_NAME", "")
11+
if not tag or os.environ.get("GITHUB_REF", "") != f"refs/tags/{tag}":
12+
sys.exit("Release requires a tag ref matching GITHUB_REF_NAME.")
13+
with Path("pyproject.toml").open("rb") as source:
14+
version = tomllib.load(source)["tool"]["poetry"]["version"]
15+
if tag not in (version, f"v{version}"):
16+
sys.exit(f"Release tag {tag} does not match package version {version}.")
17+
18+
19+
if __name__ == "__main__":
20+
main()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Fetch origin/main immediately before calling this script. Never release a
5+
# commit that has only appeared on an unreviewed branch or tag.
6+
if [[ -z "${GITHUB_REF_NAME:-}" || "${GITHUB_REF:-}" != "refs/tags/${GITHUB_REF_NAME}" || -z "${GITHUB_SHA:-}" ]]; then
7+
echo "::error::Release requires a tag push and an event commit."
8+
exit 1
9+
fi
10+
11+
release_commit=$(git rev-parse --verify "${GITHUB_SHA}^{commit}")
12+
checkout_commit=$(git rev-parse --verify 'HEAD^{commit}')
13+
if [[ "$checkout_commit" != "$release_commit" ]]; then
14+
echo "::error::Checkout does not match the release event commit."
15+
exit 1
16+
fi
17+
18+
if ! git merge-base --is-ancestor "$release_commit" refs/remotes/origin/main; then
19+
echo "::error::Release commit must already be merged into protected main."
20+
exit 1
21+
fi
22+
23+
echo "Release commit $release_commit is on protected main."

‎.github/workflows/ci.yml‎

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
name: ci
22
on: [push, pull_request, workflow_dispatch]
33

4+
permissions:
5+
contents: read
6+
47
concurrency:
58
group: ${{ github.workflow }}-${{ github.ref }}
69
cancel-in-progress: false
@@ -22,7 +25,46 @@ jobs:
2225
run: poetry install
2326
- name: Compile
2427
run: poetry run mypy .
28+
29+
package:
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 15
32+
steps:
33+
- name: Checkout repo
34+
uses: actions/checkout@v4
35+
with:
36+
persist-credentials: false
37+
fetch-depth: 0
38+
- name: Set up python
39+
uses: actions/setup-python@v7
40+
with:
41+
python-version: "3.11"
42+
- name: Test release validation
43+
run: python -m unittest discover -s .github/scripts -p 'test_*.py' -v
44+
- name: Verify release commit and version
45+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
46+
run: |
47+
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
48+
bash .github/scripts/verify-release-commit.sh
49+
python .github/scripts/validate-release.py
50+
- name: Install packaging tools
51+
run: python -m pip install "build>=1.2,<2" "twine>=6,<7"
52+
- name: Build and validate distributions
53+
run: |
54+
python -m build --outdir dist
55+
python -m twine check --strict dist/*
56+
- name: Upload Python distributions
57+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
58+
with:
59+
name: python-distributions
60+
path: |
61+
dist/*.whl
62+
dist/*.tar.gz
63+
if-no-files-found: error
64+
retention-days: 1
65+
2566
test:
67+
needs: package
2668
runs-on: ubuntu-latest
2769
strategy:
2870
fail-fast: false
@@ -41,23 +83,23 @@ jobs:
4183
uses: actions/setup-python@v7
4284
with:
4385
python-version: ${{ matrix.python }}
44-
- name: Build and install wheel
86+
- name: Download Python distributions
87+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
88+
with:
89+
name: python-distributions
90+
path: dist
91+
- name: Install built wheel
4592
env:
4693
PYDANTIC_REQUIREMENT: ${{ matrix.pydantic }}
4794
run: |
48-
python -m pip wheel --no-deps --wheel-dir dist .
4995
python -m pip install dist/*.whl "pydantic$PYDANTIC_REQUIREMENT" "pytest>=8.2,<9" "pytest-asyncio>=1,<2" "pytest-xdist>=3.6.1,<4" "python-dateutil>=2.9,<3"
5096
python -m pip check
51-
5297
- name: Test
5398
run: python -m pytest -rP -n auto tests
54-
5599
- name: Install aiohttp extra
56100
run: python -m pip install "$(find dist -name '*.whl')[aiohttp]"
57-
58101
- name: Test (aiohttp)
59102
run: python -m pytest -rP -n auto -m aiohttp tests
60-
61103
- name: Verify installed package version and request headers
62104
run: |
63105
python - <<'PY'
@@ -71,34 +113,19 @@ jobs:
71113
PY
72114
73115
publish:
74-
needs: [compile, test]
75-
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
116+
needs: [compile, package, test]
117+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
76118
runs-on: ubuntu-latest
119+
timeout-minutes: 5
120+
environment: pypi
121+
permissions:
122+
id-token: write
77123
steps:
78-
- name: Checkout repo
79-
uses: actions/checkout@v4
80-
- name: Set up python
81-
uses: actions/setup-python@v7
124+
# No checkout, dependency installation, or package build in this job.
125+
- name: Download Python distributions
126+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
82127
with:
83-
python-version: "3.10"
84-
- name: Bootstrap poetry
85-
run: |
86-
curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1
87-
- name: Install dependencies
88-
run: poetry install
89-
- name: Verify tag matches package version
90-
env:
91-
RELEASE_TAG: ${{ github.ref_name }}
92-
run: |
93-
version="$(poetry version --short)"
94-
if [ "$RELEASE_TAG" != "$version" ] && [ "$RELEASE_TAG" != "v$version" ]; then
95-
echo "Tag $RELEASE_TAG does not match package version $version"
96-
exit 1
97-
fi
98-
- name: Publish to pypi
99-
run: |
100-
poetry config repositories.remote https://upload.pypi.org/legacy/
101-
poetry --no-interaction -v publish --build --repository remote --username "$PYPI_USERNAME" --password "$PYPI_PASSWORD"
102-
env:
103-
PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}
104-
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
128+
name: python-distributions
129+
path: dist
130+
- name: Publish tested distributions to PyPI
131+
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1

0 commit comments

Comments
 (0)