diff --git a/.github/artifactory-oidc/action.yml b/.github/artifactory-oidc/action.yml deleted file mode 100644 index 98a7ed450..000000000 --- a/.github/artifactory-oidc/action.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: "Artifactory OIDC Auth" -description: "Exchange GitHub OIDC token for Artifactory access and configure pip indexes" - -inputs: - artifactory-url: - description: "Base Artifactory platform URL. Falls back to ARTIFACTORY_URL env var if not provided." - required: false - default: "" - oidc-provider-name: - description: "OIDC provider name configured in Artifactory" - required: false - default: "github-actions" - pypi-repo: - description: "Artifactory virtual PyPI repository name" - required: false - default: "virtual-pypi-thirdparty" - -runs: - using: "composite" - steps: - - name: Exchange GitHub OIDC token for Artifactory token - shell: bash - env: - INPUT_ARTIFACTORY_URL: ${{ inputs.artifactory-url }} - OIDC_PROVIDER_NAME: ${{ inputs.oidc-provider-name }} - PYPI_REPO: ${{ inputs.pypi-repo }} - run: | - set -euo pipefail - ARTIFACTORY_URL="${INPUT_ARTIFACTORY_URL:-${ARTIFACTORY_URL:-}}" - if [ -z "${ARTIFACTORY_URL}" ]; then - echo "::error::ARTIFACTORY_URL is not set (pass as input or set as env var)"; exit 1 - fi - - OIDC_JWT=$(curl -sS \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${ARTIFACTORY_URL}" \ - -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" | jq -r '.value') - - if [ -z "$OIDC_JWT" ] || [ "$OIDC_JWT" = "null" ]; then - echo "::error::Failed to obtain GitHub OIDC token"; exit 1 - fi - - decode_seg() { local s="${1}"; local m=$(( ${#s} % 4 )); [ $m -ne 0 ] && s="${s}$(printf '=%.0s' $(seq 1 $((4-m))))"; echo "$s" | tr '_-' '/+' | base64 -d 2>/dev/null; } - PAYLOAD=$(decode_seg "$(echo "$OIDC_JWT" | cut -d. -f2)") - echo "OIDC token claims:" - echo " sub = $(echo "$PAYLOAD" | jq -r '.sub')" - echo " aud = $(echo "$PAYLOAD" | jq -r '.aud')" - echo " iss = $(echo "$PAYLOAD" | jq -r '.iss')" - - RESP=$(curl -sS "${ARTIFACTORY_URL}/access/api/v1/oidc/token" \ - -H 'Content-Type: application/json' \ - -d "{\"grant_type\":\"urn:ietf:params:oauth:grant-type:token-exchange\", - \"subject_token_type\":\"urn:ietf:params:oauth:token-type:id_token\", - \"subject_token\":\"${OIDC_JWT}\", - \"provider_name\":\"${OIDC_PROVIDER_NAME}\"}") - - ART_TOKEN=$(echo "$RESP" | jq -r '.access_token // empty') - - if [ -z "$ART_TOKEN" ]; then - echo "::error::OIDC token exchange failed. Raw response (token field redacted):" - echo "$RESP" | jq 'if .access_token then .access_token="" else . end' 2>/dev/null || echo "$RESP" - exit 1 - fi - echo "::add-mask::$ART_TOKEN" - - HOST=$(echo "${ARTIFACTORY_URL}" | sed -E 's#^https?://##') - INDEX_URL="https://:${ART_TOKEN}@${HOST}/artifactory/api/pypi/${PYPI_REPO}/simple/" - - echo "::add-mask::${INDEX_URL}" - echo "PIP_INDEX_URL=${INDEX_URL}" >> "$GITHUB_ENV" - echo "PIP_TRUSTED_HOST=${HOST}" >> "$GITHUB_ENV" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..e00bdbb4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,135 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + inputs: + python-version: + description: "Python version to test with (or 'all' for full matrix)" + required: false + default: "all" + schedule: + - cron: '30 3 * * 1' # Monday 9AM IST + +env: + ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }} + +jobs: + lockfile-hygiene: + runs-on: ubuntu-x64 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: twilio/sdk-actions/uv-lockfile-hygiene@23b656b843d2a3461cf38e4fd466c07a822d5fc0 # v1 + with: + clean-room: 'false' + + test: + name: Test - Python ${{ matrix.python-version }} + needs: [lockfile-hygiene] + runs-on: ubuntu-x64 + if: github.repository_owner == 'twilio' + # make docs takes ~15mins + timeout-minutes: 30 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + python-version: ${{ (github.event_name == 'schedule' || github.event.inputs.python-version == 'all') && fromJson('["3.8","3.9","3.10","3.11","3.12","3.13"]') || fromJson(format('["{0}"]', github.event.inputs.python-version || '3.12')) }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Artifactory OIDC Auth + uses: twilio/sdk-actions/artifactory-oidc@23b656b843d2a3461cf38e4fd466c07a822d5fc0 # v1 + with: + ecosystem: python + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install virtualenv --upgrade + make install test-install + make prettier + + - name: Run tests + run: make test-with-coverage + + - name: Run cluster tests + if: ${{ !github.event.pull_request.head.repo.fork }} + env: + TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} + TWILIO_API_KEY: ${{ secrets.TWILIO_CLUSTER_TEST_API_KEY }} + TWILIO_API_SECRET: ${{ secrets.TWILIO_CLUSTER_TEST_API_KEY_SECRET }} + TWILIO_FROM_NUMBER: ${{ secrets.TWILIO_FROM_NUMBER }} + TWILIO_TO_NUMBER: ${{ secrets.TWILIO_TO_NUMBER }} + TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} + ASSISTANT_ID: ${{ secrets.ASSISTANT_ID }} + run: make cluster-test + + - name: Verify docs generation + run: make docs + + deploy-dry-run: + name: Release Readiness Check - Build artifact + needs: [test] + runs-on: ubuntu-x64 + if: github.repository_owner == 'twilio' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Artifactory OIDC Auth + uses: twilio/sdk-actions/artifactory-oidc@23b656b843d2a3461cf38e4fd466c07a822d5fc0 # v1 + with: + ecosystem: python + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install setuptools + run: pip install setuptools + + - name: Validate version format + run: | + PKG_VERSION=$(python setup.py --version) + echo "Package version: $PKG_VERSION" + echo "PKG_VERSION=$PKG_VERSION" >> "$GITHUB_ENV" + if [[ ! "$PKG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::error::setup.py version must follow semver (got '$PKG_VERSION')" + exit 1 + fi + + - name: Build package + run: | + pip install build + python -m build + + - name: Verify package + run: | + pip install twine + twine check dist/* + + - name: List built artifacts + run: | + echo "Built artifacts:" + ls -lh dist/ + echo "" + echo "Package version: $PKG_VERSION" + + - name: Dry run summary + run: | + echo "--- DRY RUN COMPLETE ---" + echo "All pre-release steps passed for version $PKG_VERSION" + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e69de29bb..f2f188c13 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -0,0 +1,117 @@ +name: Twilio Public release-pypi + +on: + push: + tags: + - 'v*' + +env: + ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }} + +jobs: + lockfile-hygiene: + runs-on: ubuntu-x64 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: twilio/sdk-actions/uv-lockfile-hygiene@23b656b843d2a3461cf38e4fd466c07a822d5fc0 # v1 + with: + clean-room: 'false' + + test: + name: Test - Python ${{ matrix.python-version }} + needs: [lockfile-hygiene] + runs-on: ubuntu-x64 + if: github.repository_owner == 'twilio' + timeout-minutes: 20 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Artifactory OIDC Auth + uses: twilio/sdk-actions/artifactory-oidc@23b656b843d2a3461cf38e4fd466c07a822d5fc0 # v1 + with: + ecosystem: python + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install virtualenv --upgrade + make install test-install + make prettier + + - name: Run tests + run: make test-with-coverage + + deploy: + name: Publish to PyPI + needs: [test] + runs-on: ubuntu-x64 + if: success() && github.ref_type == 'tag' && github.repository_owner == 'twilio' + environment: production + permissions: + contents: write # required for creating GitHub Release + id-token: write + attestations: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "${{ github.ref_name }}" --generate-notes + + - name: Artifactory OIDC Auth + uses: twilio/sdk-actions/artifactory-oidc@23b656b843d2a3461cf38e4fd466c07a822d5fc0 # v1 + with: + ecosystem: python + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install setuptools + run: pip install setuptools + + - name: Validate tag format and version match + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::error::Release tag must be in the form v1.2.3 (got '$TAG')" + exit 1 + fi + VERSION="${TAG#v}" + PKG_VERSION=$(python setup.py --version) + echo "Package version: $PKG_VERSION" + echo "PKG_VERSION=$PKG_VERSION" >> "$GITHUB_ENV" + if [ "$VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag $TAG does not match setup.py version $PKG_VERSION" + exit 1 + fi + + - name: Build package + run: | + pip install build + python -m build + + - name: Verify package + run: | + pip install twine + twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1 + + - name: Summary + run: | + echo "Published version $PKG_VERSION to PyPI" diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml deleted file mode 100644 index d5f98383c..000000000 --- a/.github/workflows/test-release.yml +++ /dev/null @@ -1,3 +0,0 @@ -name: Test and Release Gate -on: - workflow_dispatch: diff --git a/release-plan.md b/release-plan.md new file mode 100644 index 000000000..570079c91 --- /dev/null +++ b/release-plan.md @@ -0,0 +1,96 @@ +# Twilio Python SDK — Release Plan + +## ci.yml (CI) + +**Triggers:** PRs to main | Push to main | Manual dispatch | Cron (Monday 9AM IST) + +### Jobs + +1. **lockfile-hygiene** _(all triggers)_ + - Checkout + - [`uv-lockfile-hygiene`](https://github.com/twilio/sdk-actions/blob/main/uv-lockfile-hygiene/action.yml) action (scan only, no clean-room install) + - Fails if internal Artifactory hosts found in `requirements*.txt` + +2. **test** (Python matrix) — _needs: lockfile-hygiene_ _(all triggers)_ + - Checkout + - [Artifactory OIDC Auth](https://github.com/twilio/sdk-actions/tree/main/artifactory-oidc) (`ecosystem: python`) + - Setup Python + - `pip install virtualenv` + `make install test-install` + `make prettier` + - `make test-with-coverage` + - Cluster tests (`make cluster-test` with 7 secrets) + - Verify docs generation (`make docs`) + - Matrix: `3.12` on PRs/pushes, `[3.8–3.13]` on cron/manual with `all` + +3. **deploy-dry-run** (Release Readiness Check - Build artifact) — _needs: test_ _(cron + manual dispatch only)_ + - Checkout + - Artifactory OIDC Auth + - Setup Python 3.12 + - Validate version format is semver (`X.Y.Z`) + - Build sdist + wheel (`python -m build`) + - Verify with `twine check dist/*` + - List artifacts + print summary + - **Does NOT publish** + +--- + +## deploy.yml — Publish to PyPI + +**Trigger:** Tag push matching `v*` + +### How to release + +1. Bump `version` in `setup.py`, merge to `main` +2. Tag and push: `git tag v9.0.1 && git push --tags` +3. Workflow fires automatically +4. Approve the `production` environment gate when prompted +5. Verify: `pip install twilio==9.0.1` + check attestations on pypi.org + +### Jobs + +1. **lockfile-hygiene** + - Checkout + - [`uv-lockfile-hygiene`](https://github.com/twilio/sdk-actions/blob/main/uv-lockfile-hygiene/action.yml) scan (no clean-room install) + +2. **test** (Python 3.8–3.13 full matrix) — _needs: lockfile-hygiene_ + - Checkout + - [Artifactory OIDC Auth](https://github.com/twilio/sdk-actions/tree/main/artifactory-oidc) (`ecosystem: python`) + - Setup Python + - `pip install virtualenv` + `make install test-install` + `make prettier` + - `make test-with-coverage` + +3. **deploy** (Publish to PyPI) — _needs: test, requires `production` env approval_ + - Checkout + - Create GitHub Release (auto-generated notes) + - Artifactory OIDC Auth + - Setup Python 3.12 + - Validate tag format (`vX.Y.Z`) + matches `setup.py` version + - Build sdist + wheel (`python -m build`) + - Verify with `twine check dist/*` + - Publish to PyPI (OIDC trusted publishing, PEP 740 attestations) + +--- + +## End-to-end Release Day Flow + +| Step | Action | +| --- | --- | +| Weekly | Monday cron runs CI workflow — confirms infra is healthy (full matrix + dry run) | +| On every PR | CI workflow runs lockfile-hygiene + test (3.12) + cluster tests + docs | +| 1 | [ **_Librarian_** ] PR: bump `setup.py` version, merge to `main` | +| 2 | [ **_Librarian_** ] `git tag vX.Y.Z && git push --tags` | +| 3 | `deploy.yml` fires automatically on tag creation, tests run (3.8–3.13) | +| 4 | [ **_Manual_** ] Approve `production` environment gate | +| 5 | GitHub Release created, package published to PyPI | +| 6 | [ **_Manual_** ] Verify: `pip install twilio==X.Y.Z` + check attestations on pypi.org | + +--- + +## Platform Team Dependencies + +| Dependency | Owner | Breaks if... | +| --- | --- | --- | +| Artifactory OIDC provider (`github-actions`) | SSC / Platform | Repo renamed, org changed, trust not configured | +| `vars.ARTIFACTORY_URL` | Repo admin | Variable not set or URL changes | +| `production` GitHub environment | Repo admin | Environment doesn't exist or approvals misconfigured | +| `ubuntu-x64` runner group | Enterprise admin | Repo not added to runner group, or runner pool down | +| PyPI trusted publisher | PyPI org admin | Not registered, or workflow filename / environment mismatch | diff --git a/tests/unit/rest/test_client.py b/tests/unit/rest/test_client.py index 5b1f04725..93338dfef 100644 --- a/tests/unit/rest/test_client.py +++ b/tests/unit/rest/test_client.py @@ -86,9 +86,7 @@ def setUp(self): mock_response.text = "" mock_response.headers = {} self.client.http_client.session = Mock() - self.client.http_client.session.prepare_request = Mock( - side_effect=lambda r: r - ) + self.client.http_client.session.prepare_request = Mock(side_effect=lambda r: r) self.client.http_client.session.merge_environment_settings = Mock( return_value={} )