Skip to content

Commit 2af3619

Browse files
zamkorusclaude
andcommitted
Attempt both uploads independently
A failed PyPI publish used to abort the job before the anaconda.org upload, so a transient failure on one index left the other unpublished and needing a manual re-upload. Each upload now records its own outcome instead of aborting, and a new report step decides the job status from both. By the time uploads start every artifact has already been built and checked, so only network calls remain and a failure in one says nothing about the other. Also makes publish_pypi: false genuinely useful for publishing to anaconda.org alone, before a PyPI trusted publisher exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3964102 commit 2af3619

2 files changed

Lines changed: 97 additions & 17 deletions

File tree

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,32 @@ never downloaded.
4848
3. **Test** — across every Python version the project supports, in one job
4949
4. **Build** — wheel + sdist, then `twine check --strict`
5050
5. **Conda** — generate the recipe from `pyproject.toml`, build with rattler-build
51-
6. **Publish** — PyPI via OIDC, then anaconda.org
51+
6. **Publish** — PyPI via OIDC and anaconda.org, independently
5252

53-
Every failure happens before the first upload. That matters because a PyPI
54-
version number is burned the moment it is uploaded and can never be reused.
53+
Everything that can fail cheaply happens in steps 1–5, so nothing is uploaded
54+
unless every artifact already exists and passes its checks. That matters because
55+
a PyPI version number is burned the moment it is uploaded and can never be
56+
reused.
57+
58+
### The two uploads are independent
59+
60+
Neither upload can stop the other from being attempted. Each records its own
61+
outcome and the job fails afterwards if either did:
62+
63+
| PyPI | anaconda.org | Job |
64+
|---|---|---|
65+
| ✅ | ✅ | passes |
66+
| ❌ | ✅ | fails — but the conda package is published |
67+
| ✅ | ❌ | fails — re-upload the conda package by hand |
68+
| skipped | ✅ | passes |
69+
70+
By the time uploads start, only network calls remain, so a failure in one says
71+
nothing about the other. Letting the anaconda.org upload proceed after a PyPI
72+
failure costs nothing — conda uploads are replaceable — and saves a manual
73+
re-upload.
74+
75+
Set `publish_pypi: false` to publish to anaconda.org alone, which is useful
76+
before a PyPI trusted publisher exists.
5577

5678
### The tag does not set the version
5779

action.yml

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -244,23 +244,45 @@ runs:
244244
--output-dir .conda-build/out \
245245
"${channels[@]}"
246246
247+
# The two uploads are independent: neither can stop the other from being
248+
# attempted. Each records its own outcome and the job status is decided
249+
# afterwards, in "Report upload results".
250+
#
251+
# Everything that can fail cheaply -- tests, build, conda build -- has
252+
# already run by this point, so reaching here means the artifacts are good
253+
# and only the network calls remain. An anaconda.org upload is replaceable,
254+
# so letting it proceed after a PyPI failure costs nothing and saves a
255+
# manual re-upload.
247256
- name: Publish to PyPI
257+
id: pypi
248258
if: inputs.dry_run != 'true' && inputs.publish_pypi == 'true'
249259
shell: bash
250260
working-directory: ${{ inputs.project_dir }}
251261
env:
252262
PUBLISH_URL: ${{ inputs.pypi_publish_url }}
253263
run: |
254-
set -euo pipefail
264+
# No -e: a failure here must not skip the anaconda.org upload.
265+
set -uo pipefail
266+
255267
# No token: uv detects the Actions OIDC environment and exchanges the
256268
# ID token for a short-lived PyPI token. Needs id-token: write on the job.
269+
rc=0
257270
if [ -n "$PUBLISH_URL" ]; then
258-
uv publish --publish-url "$PUBLISH_URL"
271+
uv publish --publish-url "$PUBLISH_URL" || rc=$?
259272
else
260-
uv publish
273+
uv publish || rc=$?
274+
fi
275+
276+
if [ "$rc" -eq 0 ]; then
277+
echo "outcome=success" >> "$GITHUB_OUTPUT"
278+
else
279+
echo "outcome=failed" >> "$GITHUB_OUTPUT"
280+
echo "::error::PyPI publish failed (exit $rc). The anaconda.org upload" \
281+
"still runs; the job fails at the end."
261282
fi
262283
263284
- name: Upload to anaconda.org
285+
id: conda
264286
if: inputs.dry_run != 'true' && inputs.anaconda_owner != ''
265287
shell: bash
266288
working-directory: ${{ inputs.project_dir }}
@@ -269,13 +291,19 @@ runs:
269291
OWNER: ${{ inputs.anaconda_owner }}
270292
LABEL: ${{ inputs.anaconda_label }}
271293
run: |
272-
set -euo pipefail
294+
# No -e, for the same reason as above: this step reports rather than aborts.
295+
set -uo pipefail
296+
297+
fail() {
298+
echo "outcome=failed" >> "$GITHUB_OUTPUT"
299+
echo "::error::$*"
300+
exit 0
301+
}
273302
274303
if [ -z "$ANACONDA_API_KEY" ]; then
275-
echo "::error::anaconda_api_key is empty. Create a token with write" \
276-
"access at https://anaconda.org/settings/access and pass it as" \
277-
"the anaconda_api_key input."
278-
exit 1
304+
fail "anaconda_api_key is empty. Create a token with write access at" \
305+
"https://anaconda.org/settings/access and pass it as the" \
306+
"anaconda_api_key input."
279307
fi
280308
281309
packages=()
@@ -284,13 +312,40 @@ runs:
284312
done < <(find .conda-build/out -type f \( -name '*.conda' -o -name '*.tar.bz2' \) | sort)
285313
286314
if [ ${#packages[@]} -eq 0 ]; then
287-
echo "::error::no conda package was produced under .conda-build/out"
288-
exit 1
315+
fail "no conda package was produced under .conda-build/out"
289316
fi
290317
291318
printf 'uploading to anaconda.org/%s (label %s):\n' "$OWNER" "$LABEL"
292319
printf ' %s\n' "${packages[@]}"
293-
rattler-build upload anaconda -o "$OWNER" -c "$LABEL" "${packages[@]}"
320+
321+
if rattler-build upload anaconda -o "$OWNER" -c "$LABEL" "${packages[@]}"; then
322+
echo "outcome=success" >> "$GITHUB_OUTPUT"
323+
else
324+
fail "anaconda.org upload failed."
325+
fi
326+
327+
# Both uploads swallow their own failure so the other still runs, so the
328+
# job status has to be decided here.
329+
- name: Report upload results
330+
if: inputs.dry_run != 'true'
331+
shell: bash
332+
env:
333+
VERSION: ${{ steps.meta.outputs.version }}
334+
PYPI: ${{ steps.pypi.outputs.outcome }}
335+
CONDA: ${{ steps.conda.outputs.outcome }}
336+
run: |
337+
set -uo pipefail
338+
printf 'PyPI: %s\n' "${PYPI:-skipped}"
339+
printf 'anaconda.org: %s\n' "${CONDA:-skipped}"
340+
341+
if [ "${PYPI:-}" = failed ] || [ "${CONDA:-}" = failed ]; then
342+
if [ "${PYPI:-}" = success ]; then
343+
echo "::error::PyPI published $VERSION but anaconda.org did not." \
344+
"Re-running this workflow will fail on PyPI as a duplicate:" \
345+
"upload the conda package by hand, or bump to the next version."
346+
fi
347+
exit 1
348+
fi
294349
295350
# Runs from the repository root, not project_dir, so a failure in prepare
296351
# cannot turn this always() step into a second, confusing error.
@@ -300,18 +355,21 @@ runs:
300355
env:
301356
VERSION: ${{ steps.meta.outputs.version }}
302357
DRY_RUN: ${{ inputs.dry_run }}
303-
PYPI: ${{ inputs.publish_pypi }}
304358
OWNER: ${{ inputs.anaconda_owner }}
305359
PROJECT_DIR: ${{ inputs.project_dir }}
360+
PYPI_OUTCOME: ${{ steps.pypi.outputs.outcome }}
361+
CONDA_OUTCOME: ${{ steps.conda.outputs.outcome }}
306362
run: |
307363
set -uo pipefail
308364
{
309365
echo "### ${VERSION:-version unknown}"
310366
if [ "$DRY_RUN" = true ]; then
311367
echo "Dry run - built and checked, nothing uploaded."
312368
else
313-
if [ "$PYPI" = true ]; then echo "- published to PyPI"; fi
314-
if [ -n "$OWNER" ]; then echo "- published to anaconda.org/$OWNER"; fi
369+
echo "| Target | Result |"
370+
echo "|---|---|"
371+
echo "| PyPI | ${PYPI_OUTCOME:-skipped} |"
372+
echo "| anaconda.org/${OWNER:-—} | ${CONDA_OUTCOME:-skipped} |"
315373
fi
316374
echo
317375
echo '```'

0 commit comments

Comments
 (0)