Skip to content

Commit 76bb64e

Browse files
authored
Shard release publishing and signing across a gated matrix (aio-libs#13226)
1 parent f486693 commit 76bb64e

2 files changed

Lines changed: 126 additions & 27 deletions

File tree

‎.github/workflows/ci-cd.yml‎

Lines changed: 125 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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/**

‎CHANGES/13226.contrib.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Split release publishing and signing across multiple jobs so each stays within the short-lived signing token lifetime, fixing intermittent release upload failures -- by :user:`bdraco`.

0 commit comments

Comments
 (0)