Skip to content

Commit 9955ae7

Browse files
authored
Harden CI: Artifactory OIDC, uv, PyPI Trusted Publishing (#535)
Harden CI: Artifactory OIDC, uv/pyproject.toml, PyPI Trusted Publishing - Migrate build system from setup.py to pyproject.toml (hatchling) + uv.lock - Add Artifactory OIDC composite action for keyless dependency resolution - Replace legacy workflows with fork-aware ci.yml (uv, SHA-pinned actions) - Add publish.yml: PyPI Trusted Publishing (OIDC, no stored tokens) - Add PyJWT[crypto] extra for RS256 OAuth support - Apply ruff formatting and set line-length = 140
1 parent fadb7bb commit 9955ae7

28 files changed

Lines changed: 1939 additions & 873 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: "Artifactory OIDC Auth"
2+
description: "Exchange GitHub OIDC token for Artifactory access token and configure uv/pip"
3+
4+
inputs:
5+
artifactory-url:
6+
description: "JFrog platform base URL for OIDC token exchange. Falls back to ARTIFACTORY_URL env var."
7+
required: false
8+
default: ""
9+
pypi-index-url:
10+
description: "Full PyPI index base URL (without /simple). Falls back to ARTIFACTORY_PYPI_URL env var, then derived from artifactory-url."
11+
required: false
12+
default: ""
13+
oidc-provider-name:
14+
description: "OIDC provider name configured in Artifactory"
15+
required: false
16+
default: "github-actions-segmentio"
17+
pypi-repo:
18+
description: "Artifactory virtual PyPI repository name (used only when deriving index URL from artifactory-url)"
19+
required: false
20+
default: "virtual-pypi-thirdparty"
21+
22+
runs:
23+
using: "composite"
24+
steps:
25+
- name: Exchange GitHub OIDC token for Artifactory token
26+
shell: bash
27+
env:
28+
INPUT_ARTIFACTORY_URL: ${{ inputs.artifactory-url }}
29+
INPUT_PYPI_INDEX_URL: ${{ inputs.pypi-index-url }}
30+
OIDC_PROVIDER_NAME: ${{ inputs.oidc-provider-name }}
31+
PYPI_REPO: ${{ inputs.pypi-repo }}
32+
run: |
33+
set -euo pipefail
34+
ARTIFACTORY_URL="${INPUT_ARTIFACTORY_URL:-${ARTIFACTORY_URL:-}}"
35+
if [ -z "${ARTIFACTORY_URL}" ]; then
36+
echo "::error::ARTIFACTORY_URL is not set (pass as input or set as env var)"; exit 1
37+
fi
38+
39+
OIDC_JWT=$(curl -sS \
40+
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${ARTIFACTORY_URL}" \
41+
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" | jq -r '.value')
42+
43+
if [ -z "$OIDC_JWT" ] || [ "$OIDC_JWT" = "null" ]; then
44+
echo "::error::Failed to obtain GitHub OIDC token"; exit 1
45+
fi
46+
47+
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; }
48+
PAYLOAD=$(decode_seg "$(echo "$OIDC_JWT" | cut -d. -f2)")
49+
echo "OIDC token claims:"
50+
echo " sub = $(echo "$PAYLOAD" | jq -r '.sub')"
51+
echo " aud = $(echo "$PAYLOAD" | jq -r '.aud')"
52+
echo " iss = $(echo "$PAYLOAD" | jq -r '.iss')"
53+
54+
RESP=$(curl -sS "${ARTIFACTORY_URL}/access/api/v1/oidc/token" \
55+
-H 'Content-Type: application/json' \
56+
-d "{\"grant_type\":\"urn:ietf:params:oauth:grant-type:token-exchange\",
57+
\"subject_token_type\":\"urn:ietf:params:oauth:token-type:id_token\",
58+
\"subject_token\":\"${OIDC_JWT}\",
59+
\"provider_name\":\"${OIDC_PROVIDER_NAME}\"}")
60+
61+
ART_TOKEN=$(echo "$RESP" | jq -r '.access_token // empty')
62+
63+
if [ -z "$ART_TOKEN" ]; then
64+
echo "::error::OIDC token exchange failed."
65+
echo "$RESP" | jq 'walk(if type == "object" then with_entries(if (.key | test("token"; "i")) then .value = "<redacted>" else . end) else . end)' 2>/dev/null \
66+
|| echo "::error::(response withheld — not valid JSON)"
67+
exit 1
68+
fi
69+
echo "::add-mask::$ART_TOKEN"
70+
71+
# Determine the PyPI index base URL:
72+
# 1. explicit input, 2. ARTIFACTORY_PYPI_URL env var, 3. derived from ARTIFACTORY_URL
73+
PYPI_BASE="${INPUT_PYPI_INDEX_URL:-${ARTIFACTORY_PYPI_URL:-}}"
74+
if [ -z "$PYPI_BASE" ]; then
75+
HOST=$(echo "${ARTIFACTORY_URL}" | sed -E 's#^https?://##')
76+
PYPI_BASE="https://${HOST}/artifactory/api/pypi/${PYPI_REPO}"
77+
fi
78+
PYPI_BASE="${PYPI_BASE%/}" # strip trailing slash
79+
80+
INDEX_URL="https://:${ART_TOKEN}@$(echo "${PYPI_BASE}" | sed -E 's#^https?://##')/simple/"
81+
82+
echo "::add-mask::${INDEX_URL}"
83+
echo "UV_INDEX_URL=${INDEX_URL}" >> "$GITHUB_ENV"
84+
echo "PIP_INDEX_URL=${INDEX_URL}" >> "$GITHUB_ENV"
85+
echo "Configured PyPI index through Artifactory"

.github/workflows/ci.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
id-token: write
11+
contents: read
12+
13+
env:
14+
ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }}
15+
16+
jobs:
17+
lint:
18+
name: Lint
19+
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }}
20+
steps:
21+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
22+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
23+
with:
24+
python-version: "3.11"
25+
- run: pip install uv
26+
- name: Authenticate with Artifactory
27+
if: ${{ !github.event.pull_request.head.repo.fork }}
28+
uses: ./.github/actions/artifactory-oidc
29+
- run: uv sync --frozen --all-extras
30+
- run: uv run ruff check .
31+
- run: uv run ruff format --check .
32+
33+
test:
34+
name: Test - Python ${{ matrix.python-version }}
35+
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }}
36+
strategy:
37+
fail-fast: false
38+
matrix:
39+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
40+
steps:
41+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
42+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
43+
with:
44+
python-version: ${{ matrix.python-version }}
45+
- run: pip install uv
46+
- name: Authenticate with Artifactory
47+
if: ${{ !github.event.pull_request.head.repo.fork }}
48+
uses: ./.github/actions/artifactory-oidc
49+
- run: uv sync --frozen --all-extras
50+
- run: uv run pytest
51+
52+
build:
53+
name: Build Package
54+
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }}
55+
steps:
56+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
57+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
58+
with:
59+
python-version: "3.11"
60+
- run: pip install uv
61+
- name: Authenticate with Artifactory
62+
if: ${{ !github.event.pull_request.head.repo.fork }}
63+
uses: ./.github/actions/artifactory-oidc
64+
- name: Build package
65+
run: uv build
66+
- name: Verify installable
67+
run: |
68+
python -m venv test-env
69+
. test-env/bin/activate
70+
pip install dist/*.whl
71+
python -c "import segment.analytics; print(f'OK: {segment.analytics.__version__}')"
72+
73+
all-checks:
74+
name: All Checks Passed
75+
needs: [lint, test, build]
76+
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }}
77+
if: always()
78+
steps:
79+
- name: Check all job statuses
80+
run: |
81+
if [ "${{ needs.lint.result }}" != "success" ] || \
82+
[ "${{ needs.test.result }}" != "success" ] || \
83+
[ "${{ needs.build.result }}" != "success" ]; then
84+
echo "One or more checks failed"
85+
exit 1
86+
fi

.github/workflows/e2e-tests.yml

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,46 @@
1-
# E2E Tests for analytics-python
2-
# Copy this file to: analytics-python/.github/workflows/e2e-tests.yml
3-
#
4-
# This workflow:
5-
# 1. Checks out the SDK and sdk-e2e-tests repos
6-
# 2. Installs the SDK and e2e-cli dependencies
7-
# 3. Runs the e2e test suite
8-
91
name: E2E Tests
102

113
on:
124
push:
13-
branches: [main, master]
5+
branches: [master]
146
pull_request:
15-
branches: [main, master]
7+
branches: [master]
168
workflow_dispatch:
179
inputs:
1810
e2e_tests_ref:
1911
description: 'Branch or ref of sdk-e2e-tests to use'
2012
required: false
2113
default: 'main'
2214

15+
permissions:
16+
id-token: write
17+
contents: read
18+
19+
env:
20+
ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }}
21+
2322
jobs:
2423
e2e-tests:
25-
# Skip on fork PRs where repo secrets aren't available
26-
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
27-
runs-on: ubuntu-latest
28-
24+
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'ubuntu-x64' }}
25+
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
2926
steps:
3027
- name: Checkout SDK
31-
uses: actions/checkout@v4
28+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
3229
with:
3330
path: sdk
3431

3532
- name: Checkout sdk-e2e-tests
36-
uses: actions/checkout@v4
33+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
3734
with:
3835
repository: segmentio/sdk-e2e-tests
3936
ref: ${{ inputs.e2e_tests_ref || 'main' }}
40-
token: ${{ secrets.E2E_TESTS_TOKEN }}
4137
path: sdk-e2e-tests
4238

43-
- name: Setup Python
44-
uses: actions/setup-python@v5
39+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
4540
with:
4641
python-version: '3.11'
4742

48-
- name: Setup Node.js
49-
uses: actions/setup-node@v4
43+
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
5044
with:
5145
node-version: '20'
5246

@@ -67,7 +61,7 @@ jobs:
6761
6862
- name: Upload test results
6963
if: always()
70-
uses: actions/upload-artifact@v4
64+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
7165
with:
7266
name: e2e-test-results
7367
path: sdk-e2e-tests/test-results/

.github/workflows/main.yml

Lines changed: 0 additions & 57 deletions
This file was deleted.

.github/workflows/publish.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Publish
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
env:
8+
ARTIFACTORY_URL: ${{ vars.ARTIFACTORY_URL }}
9+
10+
jobs:
11+
test:
12+
name: Test - Python ${{ matrix.python-version }}
13+
runs-on: ${{ github.repository_owner == 'twilio' && 'ubuntu-x64' || 'ubuntu-latest' }}
14+
if: github.repository_owner == 'twilio'
15+
permissions:
16+
contents: read
17+
id-token: write
18+
timeout-minutes: 20
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
23+
steps:
24+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
25+
- name: Authenticate with Artifactory
26+
uses: ./.github/actions/artifactory-oidc
27+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
28+
with:
29+
python-version: ${{ matrix.python-version }}
30+
- run: pip install uv
31+
- run: uv sync --frozen --all-extras
32+
- run: uv run pytest
33+
34+
deploy:
35+
name: Publish to PyPI
36+
needs: [test]
37+
runs-on: ${{ github.repository_owner == 'twilio' && 'ubuntu-x64' || 'ubuntu-latest' }}
38+
if: github.repository_owner == 'twilio'
39+
environment: production
40+
permissions:
41+
contents: read
42+
id-token: write
43+
attestations: write
44+
steps:
45+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
46+
- name: Authenticate with Artifactory
47+
uses: ./.github/actions/artifactory-oidc
48+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
49+
with:
50+
python-version: "3.12"
51+
- run: pip install uv
52+
53+
- name: Validate tag format and version match
54+
run: |
55+
TAG="${GITHUB_REF#refs/tags/}"
56+
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then
57+
echo "::error::Release tag must be in the form v1.2.3 (got '$TAG')"
58+
exit 1
59+
fi
60+
VERSION="${TAG#v}"
61+
PKG_VERSION=$(python -c "
62+
import tomllib
63+
with open('pyproject.toml', 'rb') as f:
64+
print(tomllib.load(f)['project']['version'])
65+
")
66+
if [ "$VERSION" != "$PKG_VERSION" ]; then
67+
echo "::error::Tag $TAG does not match pyproject.toml version $PKG_VERSION"
68+
exit 1
69+
fi
70+
71+
- name: Build package
72+
run: uv build
73+
74+
- name: Publish to PyPI
75+
run: |
76+
pip install "twine>=5.0"
77+
twine upload --repository pypi --trusted-publishing=always dist/*

0 commit comments

Comments
 (0)