Skip to content
Open
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
70 changes: 70 additions & 0 deletions .github/artifactory-oidc/action.yml
Original file line number Diff line number Diff line change
@@ -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="<redacted>" 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"
84 changes: 84 additions & 0 deletions .github/deploy.yml
Original file line number Diff line number Diff line change
@@ -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

@semgrep-code-twilio semgrep-code-twilio Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

🎈 Fixed in commit 8741919 🎈

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

@semgrep-code-twilio semgrep-code-twilio Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

🥳 Fixed in commit 8741919 🥳


- name: Authenticate with Artifactory
uses: ./.github/actions/artifactory-oidc

- uses: actions/setup-python@v5

@semgrep-code-twilio semgrep-code-twilio Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

🍰 Fixed in commit 8741919 🍰

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

@semgrep-code-twilio semgrep-code-twilio Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An action sourced from a third-party repository on GitHub is not pinned to a full length commit SHA. Pinning an action to a full length commit SHA is currently the only way to use an action as an immutable release. Pinning to a particular SHA helps mitigate the risk of a bad actor adding a backdoor to the action's repository, as they would need to generate a SHA-1 collision for a valid Git object payload.

🚀 Fixed in commit 8741919 🚀

@semgrep-code-twilio semgrep-code-twilio Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Fixed in commit 8741919

90 changes: 90 additions & 0 deletions .github/workflows/test-release.yml
Original file line number Diff line number Diff line change
@@ -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."
8 changes: 8 additions & 0 deletions requirements.in
Original file line number Diff line number Diff line change
@@ -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
60 changes: 53 additions & 7 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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