@@ -753,7 +753,7 @@ jobs:
753753 path : ./wheelhouse/*.whl
754754
755755 deploy :
756- name : Deploy
756+ name : Deploy (${{ matrix.group }})
757757 needs :
758758 - build-tarball
759759 - build-wheels
@@ -766,12 +766,54 @@ jobs:
766766 contents : write # IMPORTANT: mandatory for making GitHub Releases
767767 id-token : write # IMPORTANT: mandatory for trusted publishing & sigstore
768768
769+ # TAG is shared by the two release-existence steps. GITHUB_TOKEN stays scoped
770+ # to the steps that need it rather than job-wide, so third-party actions in
771+ # this job never see it in their environment.
772+ env :
773+ TAG : ${{ github.ref_name }}
774+
775+ # The required-reviewer pypi environment gates this job, so a human must
776+ # approve before anything is created or published. Release creation and
777+ # publishing all live in this one gated matrix, so a release needs a single
778+ # approval: the groups are pending together and a reviewer approves them in
779+ # one review (see the strategy comment below).
769780 environment :
770781 name : pypi
771782 url : https://pypi.org/p/aiohttp
772783
784+ strategy :
785+ # The PyPI publish and the Sigstore signing each mint one short-lived OIDC
786+ # identity per job and reuse it for every file, so signing the whole dist
787+ # set in a single job can outlast the token and fail partway through
788+ # (pypa/gh-action-pypi-publish#307). Splitting the work across groups, each
789+ # its own job with a fresh identity signing only its share, keeps every
790+ # signing loop well under the token lifetime.
791+ #
792+ # The groups run in parallel and all target the pypi environment, so they
793+ # are pending for approval at the same time and a reviewer approves them in
794+ # a single review rather than one prompt per group. The first group
795+ # (job-index 0) creates the GitHub Release and the others wait for it; each
796+ # group only ever touches its own disjoint share of dists, so the
797+ # concurrent Release asset uploads never collide. fail-fast is off and
798+ # every step is idempotent, so a single failed group can be re-run on its
799+ # own.
800+ #
801+ # Each label "N of M" self-encodes its own position and total, which is the
802+ # only source of truth for the split. Keep `group` the sole matrix axis: an
803+ # include/exclude entry would renumber strategy.job-index / job-total, but
804+ # the label-derived split below stays correct as long as job-index 0 is the
805+ # first label.
806+ fail-fast : false
807+ matrix :
808+ group :
809+ - 1 of 2
810+ - 2 of 2
811+
773812 steps :
774813 - name : Checkout
814+ # Only the release-creating group needs the repo (create-release reads
815+ # CHANGES.rst and aiohttp/__init__.py); the others only touch dist/.
816+ if : ${{ strategy.job-index == 0 }}
775817 uses : actions/checkout@v7
776818 with :
777819 submodules : true
@@ -784,32 +826,64 @@ jobs:
784826 path : dist
785827 pattern : dist-*
786828 merge-multiple : true
787- - name : Collected dists
829+ - name : Select this group's distributions
830+ # Keep only this group's share of the dists so the job signs a bounded set.
831+ # index and count come from the "N of M" label, the single source of truth
832+ # for the split; to add a group, extend the matrix list above (e.g.
833+ # "1 of 3" .. "3 of 3").
834+ #
835+ # The split is fully deterministic: the same built dists always sort the
836+ # same way (LC_ALL=C, byte order, independent of runner locale) and land in
837+ # the same group, so re-running a single failed group reprocesses exactly
838+ # its own share and never touches another group's dists.
839+ id : group
840+ shell : bash
841+ env :
842+ GROUP : ${{ matrix.group }}
788843 run : |
789- tree dist
844+ set -euo pipefail
845+ index=$(( ${GROUP%% of *} - 1 ))
846+ count=${GROUP##* of }
847+ shopt -s nullglob
848+ mapfile -t all < <(printf '%s\n' dist/*.whl dist/*.tar.gz | LC_ALL=C sort)
849+ i=0
850+ inputs=()
851+ for f in "${all[@]}"; do
852+ if [ "$(( i % count ))" -eq "${index}" ]; then
853+ inputs+=("${f}")
854+ else
855+ rm -f -- "${f}"
856+ fi
857+ i=$(( i + 1 ))
858+ done
859+ echo "Group ${GROUP} keeps ${#inputs[@]} of ${#all[@]} dist(s):"
860+ printf ' %s\n' "${inputs[@]}"
861+ echo "sigstore-inputs=${inputs[*]}" >> "${GITHUB_OUTPUT}"
790862 - name : Check whether the GitHub Release already exists
791- # Allows re-running the deploy job after a partial failure (e.g. PyPI
792- # upload error) without the Make Release step failing with HTTP 422
793- # because the tag/release was created on a prior attempt. Treat
794- # only the literal `release not found` reply as "does not exist";
795- # other failures (auth, rate-limit, network) re-raise so the job
796- # fails loudly instead of falling through to Make Release.
863+ # The first group owns Release creation. Skipping Make Release when the
864+ # release already exists lets the job be re-run after a partial failure
865+ # without hitting HTTP 422. Query the API and branch on the HTTP status,
866+ # not on prose: a 404 means "create it", any other failure (auth,
867+ # rate-limit, network) re-raises so the job fails loudly.
868+ if : ${{ strategy.job-index == 0 }}
797869 id : gh-release
798870 env :
799871 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
800- TAG : ${{ github.ref_name }}
801872 run : |
802- if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" \
803- >/dev/null 2>err; then
873+ set -euo pipefail
874+ if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
875+ --silent 2>err; then
804876 echo 'exists=true' >> "${GITHUB_OUTPUT}"
805- elif grep -qx 'release not found ' err; then
877+ elif grep -q 'HTTP 404 ' err; then
806878 echo 'exists=false' >> "${GITHUB_OUTPUT}"
807879 else
808880 cat err >&2
809881 exit 1
810882 fi
811883 - name : Make Release
812- if : steps.gh-release.outputs.exists != 'true'
884+ # The first group creates the Release and uploads its share of the
885+ # packages; the other groups add their packages and signatures below.
886+ if : ${{ strategy.job-index == 0 && steps.gh-release.outputs.exists != 'true' }}
813887 uses : aio-libs/create-release@v1.6.6
814888 with :
815889 changes_file : CHANGES.rst
@@ -821,28 +895,52 @@ jobs:
821895 :issue:`(\d+)`
822896 fix_issue_repl : >-
823897 #\1
824-
825- - name : >-
826- Publish 🐍📦 to PyPI
898+ - name : Wait for the GitHub Release
899+ # The other groups do not create the Release; they wait for the first
900+ # group to create it before they publish or upload anything, so a failure
901+ # to create the Release blocks the irreversible PyPI upload too. Only a
902+ # 404 counts as "not yet"; any other API failure re-raises immediately
903+ # instead of silently retrying for the whole timeout.
904+ if : ${{ strategy.job-index != 0 }}
905+ env :
906+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
907+ run : |
908+ set -euo pipefail
909+ for _ in $(seq 1 150); do
910+ if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
911+ --silent 2>err; then
912+ exit 0
913+ fi
914+ if ! grep -q 'HTTP 404' err; then
915+ cat err >&2
916+ exit 1
917+ fi
918+ sleep 2
919+ done
920+ echo "GitHub Release ${TAG} did not appear in time" >&2
921+ exit 1
922+ - name : Publish 🐍📦 to PyPI
827923 uses : pypa/gh-action-pypi-publish@release/v1
828924 with :
829- # Allow re-running the deploy job after a partial PyPI upload
830- # without failing on dists that were already published.
925+ # Allow re-running after a partial PyPI upload without failing on
926+ # dists that a prior attempt already published.
831927 skip-existing : true
832-
833928 - name : Sign the dists with Sigstore
834929 uses : sigstore/gh-action-sigstore-python@v3.4.0
835930 with :
836- inputs : >-
837- ./dist/*.tar.gz
838- ./dist/*.whl
839-
931+ inputs : ${{ steps.group.outputs.sigstore-inputs }}
840932 - name : Upload artifact signatures to GitHub Release
841933 # Confusingly, this action also supports updating releases, not
842- # just creating them. This is what we want here, since we've manually
934+ # just creating them. This is what we want here, since the first group
843935 # created the release above.
936+ #
937+ # The groups run this concurrently against the same release, which is safe:
938+ # each group's files are a disjoint share, so asset names never collide, and
939+ # with no body/name inputs the action preserves the existing release
940+ # metadata (it writes back what it reads) rather than clearing it, so the
941+ # concurrent metadata updates are identical no-ops. The Wait step above
942+ # guarantees the release (with its notes) already exists first.
844943 uses : softprops/action-gh-release@v3.0.2
845944 with :
846- # dist/ contains the built packages, which smoketest-artifacts/
847- # contains the signatures and certificates.
945+ # dist/ holds this group's packages plus their Sigstore signatures.
848946 files : dist/**
0 commit comments