포스트

GitHub Actions OIDC로 PyPI Trusted Publishing 구성하기

Python 배포물 빌드·검증·OIDC 인증·PyPI 업로드를 분리하고 Trusted Publisher 불일치를 진단하는 절차

GitHub Actions OIDC로 PyPI Trusted Publishing 구성하기

이 글은 PyPI 배포 시 장기 API 토큰 대신 GitHub Actions OIDC를 사용하는 구성·검증 절차다. 명령어 문법의 암기보다 빌드, 인증, 업로드 단계와 실패 원인을 구분하는 데 초점을 둔다.

GitHub Actions의 OpenID Connect(OIDC) 신원으로 PyPI에 배포하는 흐름. 장기 PYPI_API_TOKEN 없이, 지정한 저장소·워크플로·환경에서만 짧은 수명의 배포 자격을 받는다.

배포 구조

1
2
3
4
5
6
7
8
9
Source / pyproject.toml
         │ uv build
         ▼
      dist/* ── twine check ──> 검증된 배포물
                                  │
GitHub Workflow ── OIDC ──> PyPI Trusted Publisher
                                  │ 단기 자격 증명
                                  ▼
                                PyPI

빌드·검사·인증·업로드는 서로 다른 단계다. uv build는 배포물을 만들고 twine check는 메타데이터를 검사한다. Trusted Publishing은 GitHub Actions의 OIDC 신원을 PyPI에 연결하므로 장기 API 토큰을 저장하지 않는다.

1
2
uv build --no-sources
uvx twine check --strict dist/*

배포는 아래 GitHub Actions Workflow의 Trusted Publisher 설정으로 수행한다. 수동 twine upload와 OIDC 자동 배포를 같은 인증 경로로 혼동하지 않는다.

1. PyPI Trusted Publisher 등록

이미 PyPI 프로젝트가 있을 때

프로젝트 페이지에서 Manage → Publishing → Add a new publisher로 이동한다. Manage → Collaborators가 아니다.

아직 PyPI 프로젝트가 없을 때

PyPI 계정 설정의 Publishing → Add a new pending publisher에서 등록한다. 첫 성공 배포가 프로젝트를 만들고 pending publisher를 일반 publisher로 전환한다. 등록한 프로젝트 이름과 빌드 메타데이터의 distribution 이름이 일치해야 한다.

GitHub publisher 입력값

PyPI 필드입력값주의
PyPI project name배포할 distribution 이름import 패키지명이 아니라 PyPI 배포 이름
OwnerGitHub 사용자 또는 조직 이름대소문자를 포함해 실제 owner 확인
Repository name저장소 이름만owner/repository 전체를 넣지 않음
Workflow name예: publish.yml.github/workflows/ 경로가 아닌 파일명만
Environment namepypiGitHub job의 environment.name과 정확히 일치

PyPI는 GitHub OIDC token의 owner/repository/workflow/environment claim을 등록값과 대조한다. workflow 파일을 rename하거나 저장소를 이전하면 publisher 설정도 갱신한다.

2. GitHub pypi environment 만들기

저장소 Settings → Environments → New environment에서 pypi를 만든다. 필요하면 여기에 required reviewers나 배포 branch/tag 규칙을 추가한다. workflow의 publish job에도 같은 환경을 선언해야 환경 claim과 보호 규칙이 적용된다.

1
2
3
environment:
  name: pypi
  url: https://pypi.org/p/<distribution-name>

3. 로컬에서 배포물 만들고 검사하기

1
2
3
rm -rf dist
uv build --no-sources
uvx twine check --strict dist/*

--no-sources는 빌드할 때 uv 전용 source 설정을 무시해, 실제 배포본이 공개 package index에서 해결 가능한지 확인하기 쉽게 한다. twine check --strict는 README 렌더링 경고도 실패로 처리한다. wheel과 source distribution(sdist)을 모두 검사한다.

4. 안전한 GitHub release workflow

빌드와 배포를 별도 job으로 둔다. build job은 산출물을 artifact로 넘기고, publish job만 OIDC token 발급 권한을 가진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# .github/workflows/publish.yml
name: Publish Python distribution

on:
  release:
    types: [published]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v10
      - name: Build distributions
        run: uv build --no-sources
      - name: Check distributions
        run: uvx twine check --strict dist/*
      - uses: actions/upload-artifact@v7
        with:
          name: python-package-distributions
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/<distribution-name>
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: python-package-distributions
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1
  • id-token: write는 OIDC token을 요청할 권한이지 저장소 쓰기 권한이 아니다.
  • password, user, PYPI_API_TOKEN을 설정하지 않는다. action이 Trusted Publishing 교환을 수행한다.
  • build job에는 id-token: write를 주지 않는다. 검증된 artifact만 publish job으로 전달한다.
  • PyPI Trusted Publishing의 publish step은 reusable workflow에서 지원되지 않는다. PyPI에 등록한 최상위 workflow에 publish job을 직접 둔다.

5. 릴리스와 배포 후 검증

릴리스 전에 버전, tag, 빌드 메타데이터가 일치하는지 확인한다. PyPI는 한 번 업로드한 파일명을 덮어쓰거나 교체하지 않는다. 잘못 올렸다면 삭제 후 같은 파일을 다시 올리는 대신 버전을 올려 새 파일명으로 배포한다.

1
2
3
4
5
6
7
# PyPI가 해당 버전과 파일을 제공하는지 확인
curl -fsSL \
  https://pypi.org/pypi/<distribution-name>/<version>/json \
  | jq '{name: .info.name, version: .info.version, files: [.urls[].filename]}'

# 현재 프로젝트/가상환경과 분리해 실제 PyPI 배포본 실행
uvx --from '<distribution-name>==<version>' <console-command> --version

CLI가 없는 라이브러리는 격리된 임시 환경에서 import를 확인한다.

1
2
3
uv run --isolated --no-project \
  --with '<distribution-name>==<version>' \
  python -c 'import <import_name>; print(<import_name>.__version__)'

배포 직후 CDN/index 반영에 짧은 지연이 있을 수 있다. JSON에는 새 버전이 있지만 설치가 안 되면 잠시 후 다시 확인하되, 로컬 cache의 기존 설치를 성공으로 오인하지 않도록 격리 실행을 유지한다.

문제 해결

증상확인할 것
invalid-publisherPyPI Manage → Publishing에 등록했는지 확인. Collaborators는 업로드 신원 설정이 아님
invalid-publisherowner, repository, workflow 파일명, environment: pypi가 등록값과 정확히 같은지 확인
신규 프로젝트를 못 찾음프로젝트 Manage가 아니라 계정 Publishing의 pending publisher를 사용했는지 확인
OIDC token을 받지 못함publish job에 permissions: id-token: write가 있는지 확인
environment 불일치GitHub에 pypi 환경이 존재하고 publish job이 environment.name: pypi를 선언했는지 확인
파일이 이미 존재함 / 400같은 distribution·version의 파일명은 재사용 불가. 버전을 올리고 dist/를 다시 빌드
build는 성공, publish는 실패artifact 이름/경로와 needs: build, PyPI publisher의 workflow 파일명을 확인

공식 문서

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.