elsa-core/.github/actions/push-nuget-packages/action.yml
Sipke Schoorstra cf38f0e119
ci: raise the feedz.io publish step timeout (#7995)
* ci: give the feedz.io push room and make it retry

The "Publish to feedz.io" step timed out twice recently (on the #7985-era
and #7991-era PRs) and passed on re-run both times. The 300s in those logs
is not the job timeout -- it is `dotnet nuget push`'s own default --timeout,
applied per push. With ~107 packages pushed sequentially from a single
glob, the feed only has to get slow under the burst for one of them to
cross that line, so this reads as the package set growing into the limit
rather than pure network flakiness.

Push four packages at a time with a 900s per-push timeout, and retry each
package up to three times with a backoff. --skip-duplicate was already
there and makes the retries idempotent -- a package that landed before the
failure is skipped on the next attempt.

A push that fails all three attempts still fails the step, and the job
keeps a bounded ceiling: 25 minutes on the step, 30 on the job.

Same package set, same feed, same credentials -- only how the pushes are
issued changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: share the hardened package push with the nuget.org job

The nuget.org publish had the same shape the feedz.io one just outgrew: a
single sequential `dotnet nuget push *.nupkg` over ~107 packages against a
300s per-push default. It has not timed out yet because it only runs on a
published release, but a timeout there is the worse one -- a half-finished
publish to nuget.org is not something a re-run cleanly repairs.

Rather than copy the retry loop into a second job, move it into a composite
action both jobs call with their own feed and key. Same behaviour on both:
four pushes in flight, 900s per push, three attempts with a backoff,
--skip-duplicate making the retries idempotent.

Two details worth calling out:

Composite actions have to be on disk, so both jobs now check out
.github/actions. It is a sparse, depth-1 checkout -- a couple of seconds,
not a clone of the repo.

The action fails when it finds no .nupkg at all. `dotnet nuget push
*.nupkg` used to fail on its own when the glob matched nothing, and moving
to `find | xargs` would have quietly turned a broken artifact upload into a
green publish of zero packages.

Step-level timeout-minutes is deliberately absent: the runner does not
honour it on a step that calls a composite action. The 30-minute job
timeout is the real ceiling on both jobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: fit the package push retry budget inside the job timeout

Three 900-second attempts plus backoff could run 2790s per package, so a
package that kept failing got the 30-minute job cancelled mid-retry --
taking its three concurrent siblings with it and leaving a release only
partially published. Budget three 420s attempts plus 45s of backoff
instead: 1305s worst case, roughly eight minutes short of the timeout.

Pin the checkout that supplies the composite action to a full commit SHA
as well; that checkout hands the action the feed API key, so a retargeted
tag would be a path to the publishing credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound the package push by a step deadline, not a per-package budget

xargs -P 4 puts the packages through in waves, and the job timeout covers
every wave, so budgeting one package's retries still let enough slow waves
run the job out of time -- and a cancelled runner leaves a release half
published with no record of what made it. Give the step a deadline
instead: no attempt starts that cannot finish before it, and running out
of time fails the step naming the packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: note the job timeout's relationship to the push deadline

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: pin the artifact download in the publishing jobs too

Both third-party actions in these jobs run before the local composite is
handed a feed API key, in the same writable workspace, so a retargeted tag
on either could substitute the composite ahead of the credential. The
checkout was pinned; the artifact download was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:44:19 +02:00

109 lines
4.3 KiB
YAML

name: Push NuGet packages
description: >
Pushes every .nupkg in the working directory to a feed, a few at a time,
retrying each package. Shared by the feedz.io and nuget.org publish jobs.
inputs:
feed-source:
description: NuGet v3 index URL of the feed to push to.
required: true
api-key:
description: API key for the feed.
required: true
push-timeout-seconds:
description: Timeout for a single `dotnet nuget push`, in seconds.
required: false
default: "420"
deadline-seconds:
description: >
How long the whole push step may take, in seconds. Keep this comfortably below the calling job's
`timeout-minutes` so checkout, artifact download and the step's own failure report all fit inside it.
required: false
default: "1200"
runs:
using: composite
steps:
- name: Push packages
shell: bash
env:
FEED_SOURCE: ${{ inputs.feed-source }}
API_KEY: ${{ inputs.api-key }}
PUSH_TIMEOUT: ${{ inputs.push-timeout-seconds }}
PUSH_DEADLINE_SECONDS: ${{ inputs.deadline-seconds }}
run: |
set -euo pipefail
mapfile -t packages < <(find . -maxdepth 1 -name '*.nupkg' | sort)
# A single `dotnet nuget push *.nupkg` failed loudly when the glob
# matched nothing; keep that, so a broken artifact upload can't pass
# as a successful publish of zero packages.
if [ ${#packages[@]} -eq 0 ]; then
echo "::error::No .nupkg files found to push to ${FEED_SOURCE}."
exit 1
fi
echo "Pushing ${#packages[@]} package(s) to ${FEED_SOURCE}"
# `dotnet nuget push` applies its default 300s timeout per push, and the
# package set has grown large enough that pushing it sequentially bumps
# into that. Push a few at a time with a longer per-push timeout and
# retry each one; --skip-duplicate keeps the retries idempotent.
#
# The callers' job timeout covers this whole step, and -P 4 means the
# packages go through in waves rather than all at once, so a per-package
# retry budget is not enough on its own: enough slow waves still run the
# job out of time, and a cancelled runner leaves a release half published
# with no record of which packages made it. So the retries are bounded by
# a deadline for the step as a whole. No attempt is started that cannot
# finish before it, and running out of time is reported as a failure
# naming the packages, rather than as a mid-push cancellation.
DEADLINE=$(( $(date +%s) + PUSH_DEADLINE_SECONDS ))
MAX_ATTEMPTS=3
MIN_ATTEMPT_SECONDS=60
push_one() {
local package="$1"
local attempt remaining timeout
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
remaining=$(( DEADLINE - $(date +%s) ))
if [ "$remaining" -lt "$MIN_ATTEMPT_SECONDS" ]; then
echo "::error::Ran out of publishing time before ${package} was pushed (attempt ${attempt}/${MAX_ATTEMPTS})"
return 1
fi
# Never let one push outlive the deadline it is being measured against.
timeout=$PUSH_TIMEOUT
if [ "$timeout" -gt "$remaining" ]; then
timeout=$remaining
fi
if dotnet nuget push "$package" \
-k "$API_KEY" \
-s "$FEED_SOURCE" \
--skip-duplicate \
--timeout "$timeout"; then
return 0
fi
if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then
break
fi
echo "::warning::Pushing ${package} failed (attempt ${attempt}/${MAX_ATTEMPTS}); retrying in $((attempt * 15))s"
sleep $((attempt * 15))
done
echo "::error::Failed to push ${package} after ${MAX_ATTEMPTS} attempts"
return 1
}
# push_one runs in a child shell via xargs, so the budget has to travel
# with it as environment variables rather than plain shell variables.
export -f push_one
export DEADLINE PUSH_TIMEOUT MAX_ATTEMPTS MIN_ATTEMPT_SECONDS
# shellcheck disable=SC2016 # $0 is the package name in the child shell.
printf '%s\0' "${packages[@]}" \
| xargs -0 -n1 -P 4 bash -c 'push_one "$0"'