diff --git a/.github/artifactory-oidc/action.yml b/.github/artifactory-oidc/action.yml new file mode 100644 index 000000000..98a7ed450 --- /dev/null +++ b/.github/artifactory-oidc/action.yml @@ -0,0 +1,70 @@ +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/deploy.yml b/.github/deploy.yml new file mode 100644 index 000000000..29c7cc363 --- /dev/null +++ b/.github/deploy.yml @@ -0,0 +1,84 @@ +name: Test and Deploy to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +env: + ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }} + +jobs: + test: + name: Test - Python ${{ matrix.python-version }} + runs-on: ${{ github.repository_owner == 'twilio-internal' && 'ubuntu-x64' || 'ubuntu-latest' }} + 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@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Authenticate with Artifactory + uses: ./.github/actions/artifactory-oidc + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install virtualenv --upgrade + make install test-install + + - name: Run tests + run: make test-with-coverage + + deploy: + name: Publish to PyPI + needs: [test] + runs-on: ${{ github.repository_owner == 'twilio-internal' && 'ubuntu-x64' || 'ubuntu-latest' }} + environment: pypi + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + + - name: Authenticate with Artifactory + uses: ./.github/actions/artifactory-oidc + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate tag matches package version + run: | + TAG="${GITHUB_REF#refs/tags/}" + # Strip leading 'v' if present + VERSION="${TAG#v}" + PKG_VERSION=$(python setup.py --version) + 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@v1 diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml new file mode 100644 index 000000000..29808ad2c --- /dev/null +++ b/.github/workflows/test-release.yml @@ -0,0 +1,90 @@ +name: Test Release (Dry Run) + +on: + workflow_dispatch: + inputs: + python-version: + description: "Python version to test with (or 'all' for full matrix)" + required: false + default: "3.12" + +env: + ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }} + +jobs: + test: + name: Test - Python ${{ matrix.python-version }} + runs-on: ${{ github.repository_owner == 'twilio-internal' && 'ubuntu-x64' || 'ubuntu-latest' }} + timeout-minutes: 20 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + python-version: ${{ 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)) }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Authenticate with Artifactory + uses: ./.github/actions/artifactory-oidc + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install virtualenv --upgrade + make install test-install + + - name: Run tests + run: make test-with-coverage + + deploy-dry-run: + name: Deploy (Dry Run - No Publish) + needs: [test] + runs-on: ${{ github.repository_owner == 'twilio-internal' && 'ubuntu-x64' || 'ubuntu-latest' }} + environment: pypi + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + + - name: Authenticate with Artifactory + uses: ./.github/actions/artifactory-oidc + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate version in setup.py + run: | + PKG_VERSION=$(python setup.py --version) + echo "Package version: $PKG_VERSION" + echo "PKG_VERSION=$PKG_VERSION" >> "$GITHUB_ENV" + + - 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" + echo "" + echo "--- DRY RUN COMPLETE ---" + echo "To publish for real, create a GitHub Release with a matching tag" + echo "and use the deploy.yml workflow." diff --git a/requirements.in b/requirements.in new file mode 100644 index 000000000..7bc9e4180 --- /dev/null +++ b/requirements.in @@ -0,0 +1,8 @@ +pygments>=2.7.4 # not directly required, pinned by Snyk to avoid a vulnerability +requests>=2.32.2 +PyJWT>=2.0.0, <3.0.0 +aiohttp>=3.10.2 +aiohttp-retry==2.8.3 +certifi>=2023.7.22 # not directly required, pinned by Snyk to avoid a vulnerability +urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability +zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/requirements.txt b/requirements.txt index 7bc9e4180..19de7b687 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,54 @@ -pygments>=2.7.4 # not directly required, pinned by Snyk to avoid a vulnerability -requests>=2.32.2 -PyJWT>=2.0.0, <3.0.0 -aiohttp>=3.10.2 +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --output-file=requirements.txt requirements.in +# +aiohappyeyeballs==2.6.2 + # via aiohttp +aiohttp==3.14.1 + # via + # -r requirements.in + # aiohttp-retry aiohttp-retry==2.8.3 -certifi>=2023.7.22 # not directly required, pinned by Snyk to avoid a vulnerability -urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability -zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability + # via -r requirements.in +aiosignal==1.4.0 + # via aiohttp +attrs==26.1.0 + # via aiohttp +certifi==2026.5.20 + # via + # -r requirements.in + # requests +charset-normalizer==3.4.7 + # via requests +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +idna==3.18 + # via + # requests + # yarl +multidict==6.7.1 + # via + # aiohttp + # yarl +propcache==0.5.2 + # via + # aiohttp + # yarl +pygments==2.20.0 + # via -r requirements.in +pyjwt==2.13.0 + # via -r requirements.in +requests==2.34.2 + # via -r requirements.in +urllib3==2.7.0 + # via + # -r requirements.in + # requests +yarl==1.24.2 + # via aiohttp +zipp==4.1.0 + # via -r requirements.in