openarena용 Trusted Publishing 릴리스 파이프라인: 지금은 TestPyPI, PyPI는 repo 플래그 뒤에
README 링크에서 발견 · today
OIDC 기반 GitHub Actions 릴리스 워크플로우: 빌드, 게시
What they did
작성자가 openarena repo에 `.github/workflows/release.yml`을 추가했으며, PyPI Trusted Publishing(OIDC, 저장된 secret 없음)으로 패키지를 배포한다. 워크플로는 `release: published`와 `workflow_dispatch`에서 트리거되고, 최상위 `permissions: contents: read`를 설정하며, 절대 취소되지 않는 `release-<ref>` concurrency 그룹을 사용하고, 모든 job에 timeout을 둔다. 네 개의 job: (1) build — 수동 실행 시 `uv version --frozen`으로 버전을 `<pyproject>.dev<run_number>`로 설정한다(pyproject만, uv.lock은 건드리지 않음). 릴리스 시에는 태그에서 `v`를 뺀 값이 pyproject와 다르면 실패한다. 그다음 `uv build`, `uvx twine check dist/*`를 실행하고, `dist/`를 artifact로 업로드하며 `version`을 출력한다. (2) publish-testpypi — 수동 실행, pre-release, 그리고 PyPI가 꺼져 있는 동안의 모든 릴리스에서 실행된다. environment는 `testpypi`, `id-token: write`, `pypa/gh-action-pypi-publish@release/v1`을 TestPyPI legacy URL로 지정한다. (3) publish-pypi — `vars.PUBLISH_PYPI == 'true'`일 때만, pre-release가 아닌 published 릴리스에서만 실행된다. environment는 `pypi`. (4) verify — 어느 쪽이든 publish가 성공한 뒤 깨끗한 Python 3.12 runner에서 실행되며, 최대 5분 동안 재시도한다(30 × 10초, `--no-cache-dir`). TestPyPI에서는 `--no-deps --only-binary :all:`로 openarena wheel만 다운로드하고 의존성은 PyPI에서 설치한다. PyPI에서는 `pip install openarena==<v>`를 실행한다. `openarena --help`가 0으로 종료되는지, `import openarena`가 되는지, `importlib.metadata.version("openarena") == <v>`인지 확인하고, 새 폴더에서 `openarena init`과 `openarena check`를 실행하며, 버전과 설치 명령을 step summary에 기록한다. PyPI는 의도적으로 꺼져 있다: `testpypi` environment의 `v*` 전용 태그 규칙이 제거되어(`gh api`로 확인: `deployment_branch_policy: null`) `main`에서의 수동 실행이 배포할 수 있다. 나중에 PyPI를 켜려면 PyPI pending publisher(environment `pypi`), 태그 `v*`로 제한된 `pypi` environment, 그다음 `PUBLISH_PYPI=true`가 필요하다. PR에는 또한 다음이 포함된다: PyPI에서 렌더링되도록 README 로고와 LICENSE 링크를 절대 경로로 변경; 새 `docs/releasing.md` 페이지(CLI reference 다음 nav)로 trial build, 릴리스, pre-release, 재업로드 금지 규칙을 다루며, `docs.yml`은 이제 최소 26페이지를 기대함; 그리고 Harbor를 `>=0.23,<0.25`로 상한을 둠 — Harbor 0.25.0이 `openarena/harbor.py`가 import하는 `harbor.agents.model_connection`을 제거했기 때문이다. `--help`는 동작했지만 `check`가 크래시했고, 0.25에서는 suite 실패 7건, 0.24에서는 521건 통과였다. 그래서 verify가 이제 `init`과 `check`를 실행한다. 로컬 검사: `uv build` + `twine check` 통과; ruff clean; pytest 521 통과, 1 skipped; zensical strict build가 26페이지로 clean; actionlint 1.7.12와 shellcheck 0.11.0이 release.yml, docs.yml, tests.yml에서 clean. 수동 실행 버전 지정은 `GITHUB_RUN_NUMBER=7`로 시뮬레이션했고(0.2.0 → 0.2.0.dev7, PEP 440 devrelease, uv.lock 미변경), pyproject 0.2.0에 대한 `v0.2.1` 태그는 태그 검사에서 올바르게 실패했다. 빌드된 wheel은 repo 밖의 새 venv에 설치했고, `--help`, import, version, `init`, `check`가 모두 정상 동작했다.
How it went
워크플로가 병합 준비를 마쳤고 로컬에서 엔드투엔드로 검증되었습니다. 빌드와 twine check가 통과하고, ruff와 pytest가 깨끗하며(521개 통과, 1개 스킵), 문서가 26페이지로 엄격하게 빌드되고, actionlint/shellcheck는 아무것도 보고하지 않습니다. 시뮬레이션된 수동 실행은 uv.lock을 건드리지 않고 올바른 PEP 440 dev 버전을 생성했으며, 태그가 일치하지 않는 경우는 의도대로 실패했습니다. 저장소 외부의 새 venv에 설치된 wheel은 `--help`를 실행하고, 임포트되고, 올바른 버전을 보고하며, `init` + `check`를 통과했습니다. 병합 후 단계가 남아 있습니다: `main`에서 워크플로를 실행하여 build, publish-testpypi, verify가 초록불이고 publish-pypi는 스킵되는지 확인한 다음, v0.2.0 드래프트 릴리스를 TestPyPI에 게시합니다.
Worth knowing
아직 실제로 게시된 것은 없습니다. 이 글은 PR과 로컬 검증을 설명하며, 최초의 실제 TestPyPI 실행은 머지 이후 단계로 나열되어 있습니다. PyPI 게시는 pending publisher, 태그로 제한된 `pypi` 환경, 그리고 `PUBLISH_PYPI=true`가 설정될 때까지 의도적으로 비활성화되어 있습니다. Harbor 핀은 임시방편입니다. 0.25에서 `harbor.agents.model_connection`이 제거되었기 때문에 `harbor.agents.agent_auth_resolution`로의 포팅은 별도의 후속 작업이며, uv.lock은 여전히 0.23.0을 유지하고 있고 specifier만 변경되었습니다. `testpypi` 환경은 더 이상 배포를 `v*` 태그로 제한하지 않으므로 `main`에서의 수동 실행으로도 해당 환경에 게시할 수 있습니다. 동일한 버전은 두 번 업로드할 수 없으므로 릴리스를 다시 실행하려면 버전을 올려야 합니다.
Wiring
순서: build → publish-testpypi 또는 publish-pypi → verify. build job은 `uv version --frozen`(수동 실행 시에만), `uv build`, `uvx twine check dist/*`를 실행하고, actions/upload-artifact로 `dist/`를 업로드하며, publish 및 verify job이 사용하는 `version` 출력을 노출한다. publish job은 `id-token: write`와 Trusted Publishing 바인딩을 보유한 GitHub environment(`testpypi` 또는 `pypi`)와 함께 `pypa/gh-action-pypi-publish@release/v1`을 사용한다. TestPyPI는 `repository-url: https://test.pypi.org/legacy/`를 전달한다. PyPI는 `if: vars.PUBLISH_PYPI == 'true'`와 non-prerelease 검사로 게이트된다. verify는 Python 3.12가 설치된 새 runner에서 실행되며, `--no-cache-dir`와 함께 10초 간격으로 설치 루프를 30회 재시도하고, 어떤 publish job이 실행되었는지에 따라 분기하여 인덱스를 선택한다. 변경된 설정: `.github/workflows/release.yml`(신규), `docs.yml`(페이지 수 기대값을 26으로 상향), `pyproject.toml`(Harbor specifier `>=0.23,<0.25`), `docs/releasing.md`(신규), README 링크. Repo 설정: `v*` 태그 규칙이 제거된 `testpypi` environment, `PUBLISH_PYPI` repository variable, 그리고 이후 `v*` 태그로 제한된 `pypi` environment와 PyPI pending publisher.
Try it yourself
# GitHub Actions Release Workflow
## `.github/workflows/release.yml`
```yaml
name: Release
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v5
with:
enable-cache: false
- name: Set dev version for manual runs
if: github.event_name == 'workflow_dispatch'
run: uv version --frozen "$(uv version --frozen --short).dev${{ github.run_number }}"
- name: Verify tag matches pyproject version
if: github.event_name == 'release'
run: |
tag="${GITHUB_REF_NAME#v}"
pkg_version="$(uv version --frozen --short)"
if [ "$tag" != "$pkg_version" ]; then
echo "::error::Tag '$GITHUB_REF_NAME' (=$tag) does not match pyproject version '$pkg_version'"
exit 1
fi
- name: Read version
id: version
run: echo "version=$(uv version --frozen --short)" >> "$GITHUB_OUTPUT"
- name: Build
run: uv build
- name: Check metadata
run: uvx twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
if-no-files-found: error
publish-testpypi:
name: Publish to TestPyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.release.prerelease ||
vars.PUBLISH_PYPI != 'true'
environment:
name: testpypi
url: https://test.pypi.org/p/${{ github.event.repository.name }}
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
publish-pypi:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
if: >-
github.event_name == 'release' &&
!github.event.release.prerelease &&
vars.PUBLISH_PYPI == 'true'
environment:
name: pypi
url: https://pypi.org/p/${{ github.event.repository.name }}
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
verify:
name: Verify install
needs: [build, publish-testpypi, publish-pypi]
if: >-
always() &&
(needs.publish-testpypi.result == 'success' || needs.publish-pypi.result == 'success')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install and smoke-test
env:
PKG: ${{ github.event.repository.name }}
VERSION: ${{ needs.build.outputs.version }}
FROM_TESTPYPI: ${{ needs.publish-testpypi.result == 'success' }}
run: |
set -euo pipefail
pkg="${PKG//-/_}"
if [ "$FROM_TESTPYPI" = "true" ]; then
install_cmd="pip download --no-deps --only-binary :all: -i https://test.pypi.org/simple/ ${PKG}==${VERSION} && pip install ${PKG}==${VERSION} --no-deps --no-cache-dir"
else
install_cmd="pip install ${PKG}==${VERSION} --no-cache-dir"
fi
ok=0
for i in $(seq 1 30); do
if [ "$FROM_TESTPYPI" = "true" ]; then
if pip download --no-deps --only-binary :all: --no-cache-dir \
-i https://test.pypi.org/simple/ \
-d /tmp/wheels "${PKG}==${VERSION}" >/dev/null 2>&1; then
if pip install --no-cache-dir --no-deps /tmp/wheels/*.whl >/dev/null 2>&1 \
&& pip install --no-cache-dir "${PKG}==${VERSION}" >/dev/null 2>&1; then
ok=1; break
fi
fi
else
if pip install --no-cache-dir "${PKG}==${VERSION}" >/dev/null 2>&1; then
ok=1; break
fi
fi
echo "Attempt $i/30 failed; retrying in 10s..."
sleep 10
done
if [ "$ok" -ne 1 ]; then
echo "::error::Package ${PKG}==${VERSION} not installable after 5 minutes"
exit 1
fi
"${PKG}" --help >/dev/null
python -c "import ${pkg}"
python -c "import importlib.metadata as m; assert m.version('${PKG}') == '${VERSION}', m.version('${PKG}')"
mkdir -p /tmp/smoke && cd /tmp/smoke
"${PKG}" init
"${PKG}" check
{
echo "### Verified \`${PKG}==${VERSION}\`"
echo ""
echo '```'
echo "$install_cmd"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
```
## `README.md` — absolute links
Replace relative image and license references so they render on PyPI:
```markdown

[LICENSE](https://github.com/OWNER/REPO/blob/main/LICENSE)
```
## `docs/releasing.md`
```markdown
# Releasing
## Trial builds
Run the **Release** workflow manually (`workflow_dispatch`). The build job sets the
version to `<pyproject>.dev<run_number>` using `uv version --frozen` (pyproject only;
`uv.lock` is not re-resolved), builds, and publishes to TestPyPI. The verify job
installs the wheel from TestPyPI with dependencies from PyPI and runs `init` + `check`.
## Releases
1. Bump the version in `pyproject.toml` and commit.
2. Tag `v<version>` and publish a GitHub release.
3. The build job fails if the tag (minus `v`) differs from the pyproject version.
4. Non-pre-releases publish to PyPI only when the repository variable
`PUBLISH_PYPI` is `true`; otherwise they go to TestPyPI.
## Pre-releases
Mark the GitHub release as a pre-release. It always publishes to TestPyPI and never
to PyPI, regardless of `PUBLISH_PYPI`.
## No re-upload rule
PyPI and TestPyPI reject re-uploading an existing version. Never reuse a version
number — bump it and cut a new release. Manual runs use `.dev<run_number>` so they
never collide.
```
## Dependency pin
If a dependency's new minor version breaks imports, pin it in `pyproject.toml`:
```toml
dependencies = [
"some-dep>=1.2,<1.3",
]
```
## Local validation
```bash
uv build
uvx twine check dist/*
uv run ruff check .
uv run pytest
uv run mkdocs build --strict
actionlint -shellcheck=shellcheck .github/workflows/release.yml
```