diff --git a/.fernignore b/.fernignore index a85a6b74..3527408d 100644 --- a/.fernignore +++ b/.fernignore @@ -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 diff --git a/.github/scripts/test_release_validation.py b/.github/scripts/test_release_validation.py new file mode 100644 index 00000000..8719f3cd --- /dev/null +++ b/.github/scripts/test_release_validation.py @@ -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() diff --git a/.github/scripts/validate-release.py b/.github/scripts/validate-release.py new file mode 100644 index 00000000..8bfba728 --- /dev/null +++ b/.github/scripts/validate-release.py @@ -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() diff --git a/.github/scripts/verify-release-commit.sh b/.github/scripts/verify-release-commit.sh new file mode 100644 index 00000000..e5e88e1a --- /dev/null +++ b/.github/scripts/verify-release-commit.sh @@ -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." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3629c04..5393e45a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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' @@ -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