Files
Alex 15c4d82bcf ci: stop attesting when the release workflow publishes to PyPI
0.20.0 failed to upload with "Certificate's Build Config URI ... does not
match expected Trusted Publisher". Authentication was fine: trusted publishing
matches the called workflow, pypi-publish.yml, which is what PyPI is
configured with. The PEP 740 attestation the publish action attaches by
default records the workflow that STARTED the run instead, backend-release.yml,
and PyPI verifies that against the same publisher entry. The two claims can
never agree while the publish runs as a called workflow, and no publisher
configuration satisfies both.

Attest only when this file is the entry point, which covers the manual
dispatch and a release published by hand. The release chain uploads unattested
rather than failing.

The earlier rehearsal went to TestPyPI and passed, so this only appeared on the
real index.
2026-09-12 23:29:33 +01:00

153 lines
5.6 KiB
YAML

name: Publish to PyPI
# Trusted publishing: PyPI trusts this workflow file in arc53/DocsGPT running
# in the `pypi` environment, so no API token is stored. The backend-release
# workflow calls this one after it tags a version bump on main (a release it
# creates with GITHUB_TOKEN never fires the release trigger); a release created
# by hand publishes through that trigger; a manual run publishes to TestPyPI
# by default.
#
# Attestations are on only when this file is the workflow that STARTED the run.
# A PEP 740 attestation records the entry workflow in its certificate, while
# trusted publishing matches the called workflow, so when backend-release calls
# this one the two disagree and PyPI rejects the upload with "Certificate's
# Build Config URI ... does not match expected Trusted Publisher". No publisher
# configuration satisfies both, so the release chain publishes unattested.
on:
release:
types: [published]
workflow_call:
inputs:
version:
description: Version the archives must carry (empty skips the check)
type: string
default: ''
target:
description: Package index to publish to (pypi or testpypi)
type: string
default: pypi
workflow_dispatch:
inputs:
target:
description: Package index to publish to
type: choice
options: [testpypi, pypi]
default: testpypi
permissions:
contents: read
env:
# The version being released: passed in by the caller, or the release's tag;
# empty for a manual run, which publishes whatever main builds.
RELEASE_VERSION: ${{ inputs.version || github.event.release.tag_name }}
jobs:
build:
if: github.repository == 'arc53/DocsGPT'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
# A publishing workflow must not restore a cache another job could have poisoned.
enable-cache: false
- name: The publish target is one this workflow knows
# A caller's typo would otherwise skip both publish jobs and upload nothing.
if: inputs.target != '' && inputs.target != 'pypi' && inputs.target != 'testpypi'
env:
TARGET: ${{ inputs.target }}
run: |
echo "::error::unknown target '$TARGET': expected pypi or testpypi"
exit 1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
# A publishing workflow must not restore a cache another job could have poisoned.
package-manager-cache: false
- name: Build the web UI into the package
env:
CI: "true"
run: bash scripts/build_frontend.sh
- name: Build the sdist and wheel
run: uv build
- name: The wheel carries the web UI and no generated runtime config
run: |
python3 - <<'PY'
import glob
import zipfile
wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0])
names = set(wheel.namelist())
assert "docsgpt/static/index.html" in names, "the UI build is missing from the wheel"
assert any(n.startswith("docsgpt/static/assets/") for n in names), "no bundle under docsgpt/static/assets/"
assert "docsgpt/static/config.js" not in names, "config.js is generated by the API at runtime"
assert b'src="/config.js"' in wheel.read("docsgpt/static/index.html"), "index.html must load /config.js"
print("ui ok:", sum(n.startswith("docsgpt/static/") for n in names), "files")
PY
- name: The archives carry the released version
if: env.RELEASE_VERSION != ''
run: |
ls "dist/docsgpt-${RELEASE_VERSION}.tar.gz" "dist/docsgpt-${RELEASE_VERSION}-py3-none-any.whl"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
if-no-files-found: error
publish-pypi:
# A release event, or a call or manual run aimed at PyPI (the call default).
if: github.event_name == 'release' || inputs.target == 'pypi'
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/docsgpt/
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
# Only when this workflow is the entry point: see the note at the top.
attestations: ${{ contains(github.workflow_ref, '/pypi-publish.yml@') }}
publish-testpypi:
if: inputs.target == 'testpypi'
needs: build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/project/docsgpt/
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
# Only when this workflow is the entry point: see the note at the top.
attestations: ${{ contains(github.workflow_ref, '/pypi-publish.yml@') }}
repository-url: https://test.pypi.org/legacy/
# TestPyPI is a rehearsal target: a version that is already there
# must not fail the run.
skip-existing: true