Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ tests/custom/test_structured_output_run.py
tests/custom/test_vapi_managed_model.py
tests/custom/test_tool_variants.py
tests/custom/test_model_deprecation_notice.py

.github/scripts/verify-release-commit.sh
.github/scripts/validate-release.py
.github/scripts/test_release_validation.py
85 changes: 85 additions & 0 deletions .github/scripts/test_release_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Exercise release guards with actual Git histories, without registry access."""

import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

SCRIPTS = Path(__file__).resolve().parent


class ReleaseValidationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="sdk-release-")
self.addCleanup(self.temp.cleanup)
self.cwd = Path(self.temp.name)
self.env = {
**os.environ,
"GIT_AUTHOR_NAME": "Release test",
"GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_NAME": "Release test",
"GIT_COMMITTER_EMAIL": "test@example.com",
}
self.git("init", "--initial-branch=main")
self.git("commit", "--allow-empty", "-m", "Approved release")
self.approved = self.git("rev-parse", "HEAD")
self.git("update-ref", "refs/remotes/origin/main", self.approved)
(self.cwd / "pyproject.toml").write_text('[tool.poetry]\nversion = "3.0.0"\n')
self.env.update(GITHUB_REF="refs/tags/v3.0.0", GITHUB_REF_NAME="v3.0.0", GITHUB_SHA=self.approved)

def git(self, *args):
return subprocess.run(["git", *args], cwd=self.cwd, env=self.env, check=True, capture_output=True, text=True).stdout.strip()

def guard(self):
return subprocess.run(["bash", str(SCRIPTS / "verify-release-commit.sh")], cwd=self.cwd, env=self.env, capture_output=True, text=True)

def test_approved_commit_and_ancestor_are_allowed(self):
self.assertEqual(self.guard().returncode, 0)
self.git("commit", "--allow-empty", "-m", "Newer approved commit")
self.git("update-ref", "refs/remotes/origin/main", self.git("rev-parse", "HEAD"))
self.git("checkout", "--detach", self.approved)
self.assertEqual(self.guard().returncode, 0)

def test_annotated_tag_is_resolved_to_commit(self):
self.git("tag", "-a", "v3.0.0", "-m", "Release")
self.env["GITHUB_SHA"] = self.git("rev-parse", "v3.0.0")
self.assertEqual(self.guard().returncode, 0)

def test_unmerged_commit_is_rejected(self):
self.git("checkout", "-b", "unapproved")
self.git("commit", "--allow-empty", "-m", "Unreviewed change")
self.env["GITHUB_SHA"] = self.git("rev-parse", "HEAD")
result = self.guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("must already be merged", result.stdout)

def test_wrong_checkout_is_rejected(self):
self.git("commit", "--allow-empty", "-m", "Different checkout")
result = self.guard()
self.assertNotEqual(result.returncode, 0)
self.assertIn("does not match", result.stdout)

def test_missing_main_or_event_commit_is_rejected(self):
self.git("update-ref", "-d", "refs/remotes/origin/main")
self.assertNotEqual(self.guard().returncode, 0)
del self.env["GITHUB_SHA"]
self.assertNotEqual(self.guard().returncode, 0)

def test_package_version_and_ref(self):
for tag in ("3.0.0", "v3.0.0"):
self.env.update(GITHUB_REF=f"refs/tags/{tag}", GITHUB_REF_NAME=tag)
result = subprocess.run([sys.executable, str(SCRIPTS / "validate-release.py")], cwd=self.cwd, env=self.env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
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"), ("", "")):
with self.subTest(ref=ref, tag=tag):
self.env.update(GITHUB_REF=ref, GITHUB_REF_NAME=tag)
if not ref.startswith("refs/tags/"):
self.assertNotEqual(self.guard().returncode, 0)
result = subprocess.run([sys.executable, str(SCRIPTS / "validate-release.py")], cwd=self.cwd, env=self.env, capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)


if __name__ == "__main__":
unittest.main()
20 changes: 20 additions & 0 deletions .github/scripts/validate-release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Validate release refs before running package build code (Python 3.11+)."""

import os
from pathlib import Path
import sys
import tomllib


def main() -> None:
tag = os.environ.get("GITHUB_REF_NAME", "")
if not tag or os.environ.get("GITHUB_REF", "") != f"refs/tags/{tag}":
sys.exit("Release requires a tag ref matching GITHUB_REF_NAME.")
with Path("pyproject.toml").open("rb") as source:
version = tomllib.load(source)["tool"]["poetry"]["version"]
if tag not in (version, f"v{version}"):
sys.exit(f"Release tag {tag} does not match package version {version}.")


if __name__ == "__main__":
main()
23 changes: 23 additions & 0 deletions .github/scripts/verify-release-commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail

# Fetch origin/main immediately before calling this script. Never release a
# commit that has only appeared on an unreviewed branch or tag.
if [[ -z "${GITHUB_REF_NAME:-}" || "${GITHUB_REF:-}" != "refs/tags/${GITHUB_REF_NAME}" || -z "${GITHUB_SHA:-}" ]]; then
echo "::error::Release requires a tag push and an event commit."
exit 1
fi

release_commit=$(git rev-parse --verify "${GITHUB_SHA}^{commit}")
checkout_commit=$(git rev-parse --verify 'HEAD^{commit}')
if [[ "$checkout_commit" != "$release_commit" ]]; then
echo "::error::Checkout does not match the release event commit."
exit 1
fi

if ! git merge-base --is-ancestor "$release_commit" refs/remotes/origin/main; then
echo "::error::Release commit must already be merged into protected main."
exit 1
fi

echo "Release commit $release_commit is on protected main."
95 changes: 61 additions & 34 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: ci
on: [push, pull_request, workflow_dispatch]

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
Expand All @@ -22,7 +25,46 @@ jobs:
run: poetry install
- name: Compile
run: poetry run mypy .

package:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Test release validation
run: python -m unittest discover -s .github/scripts -p 'test_*.py' -v
- name: Verify release commit and version
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
run: |
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
bash .github/scripts/verify-release-commit.sh
python .github/scripts/validate-release.py
- name: Install packaging tools
run: python -m pip install "build>=1.2,<2" "twine>=6,<7"
- name: Build and validate distributions
run: |
python -m build --outdir dist
python -m twine check --strict dist/*
- name: Upload Python distributions
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: python-distributions
path: |
dist/*.whl
dist/*.tar.gz
if-no-files-found: error
retention-days: 1

test:
needs: package
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand All @@ -41,23 +83,23 @@ jobs:
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
- name: Build and install wheel
- name: Download Python distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-distributions
path: dist
- name: Install built wheel
env:
PYDANTIC_REQUIREMENT: ${{ matrix.pydantic }}
run: |
python -m pip wheel --no-deps --wheel-dir dist .
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"
python -m pip check

- name: Test
run: python -m pytest -rP -n auto tests

- name: Install aiohttp extra
run: python -m pip install "$(find dist -name '*.whl')[aiohttp]"

- name: Test (aiohttp)
run: python -m pytest -rP -n auto -m aiohttp tests

- name: Verify installed package version and request headers
run: |
python - <<'PY'
Expand All @@ -71,34 +113,19 @@ jobs:
PY

publish:
needs: [compile, test]
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
needs: [compile, package, test]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
timeout-minutes: 5
environment: pypi
permissions:
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up python
uses: actions/setup-python@v7
# No checkout, dependency installation, or package build in this job.
- name: Download Python distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
python-version: "3.10"
- name: Bootstrap poetry
run: |
curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1
- name: Install dependencies
run: poetry install
- name: Verify tag matches package version
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
version="$(poetry version --short)"
if [ "$RELEASE_TAG" != "$version" ] && [ "$RELEASE_TAG" != "v$version" ]; then
echo "Tag $RELEASE_TAG does not match package version $version"
exit 1
fi
- name: Publish to pypi
run: |
poetry config repositories.remote https://upload.pypi.org/legacy/
poetry --no-interaction -v publish --build --repository remote --username "$PYPI_USERNAME" --password "$PYPI_PASSWORD"
env:
PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
name: python-distributions
path: dist
- name: Publish tested distributions to PyPI
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
Loading