elsa-core/.github/workflows/packages.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

403 lines
14 KiB
YAML

name: Packages
on:
workflow_dispatch:
push:
branches:
- 'main'
- 'bug/*'
- 'perf/*'
- 'patch/*'
- 'feat/*'
- 'enh/*'
- 'rc/*'
- 'develop/*'
- 'release/*'
- 'codex/*'
release:
types: [prereleased, published]
env:
base_version: '3.8.0'
feedz_feed_source: 'https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json'
nuget_feed_source: 'https://api.nuget.org/v3/index.json'
jobs:
test_unit_integration:
name: Test unit/integration with coverage
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.1xx
- name: Show .NET info
run: |
dotnet --info
dotnet --list-sdks
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore solution
run: dotnet restore Elsa.sln --ignore-failed-sources
- name: Prepare coverage directory
run: |
rm -rf artifacts/coverage
mkdir -p artifacts/coverage
- name: Run tests with coverage
run: |
set -euo pipefail
dotnet --info
if ! dotnet --version | grep -q '^10\.'; then
echo "Expected .NET SDK 10.x but got $(dotnet --version)" >&2
exit 1
fi
# Discover unit and integration test projects. Component tests run in their own job.
mapfile -t projects < <(
find test/unit test/integration \
-type f -name '*.csproj' -print0 2>/dev/null \
| xargs -0 -n1 \
| sort
)
if [ ${#projects[@]} -eq 0 ]; then
echo "No test projects were found in test/unit or test/integration." >&2
exit 1
fi
for project in "${projects[@]}"; do
echo "Building test project ${project}"
dotnet build "$project" --configuration Release --framework net10.0
echo "Running tests with coverage for ${project}"
dotnet test "$project" \
--configuration Release \
--framework net10.0 \
--no-build \
--logger "GitHubActions;report-warnings=false" \
/p:CollectCoverage=true
done
- name: Dump docker logs on failure
if: failure()
run: |
echo "=== Docker containers ==="
docker ps -a || true
echo "=== Docker logs ==="
for container in $(docker ps -aq); do
echo "--- Logs for container $container ---"
docker logs "$container" 2>&1 | tail -100 || true
done
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-integration-coverage-reports
path: artifacts/coverage
test_component:
name: Test component with coverage
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.1xx
- name: Show .NET info
run: |
dotnet --info
dotnet --list-sdks
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore solution
run: dotnet restore Elsa.sln --ignore-failed-sources
- name: Prepare test artifact directories
run: |
rm -rf artifacts/coverage artifacts/test-results
mkdir -p artifacts/coverage artifacts/test-results/component
- name: Run component tests with coverage and hang diagnostics
run: |
set -euo pipefail
dotnet --info
if ! dotnet --version | grep -q '^10\.'; then
echo "Expected .NET SDK 10.x but got $(dotnet --version)" >&2
exit 1
fi
mapfile -t projects < <(
find test/component \
-type f -name '*.csproj' -print0 2>/dev/null \
| xargs -0 -n1 \
| sort
)
if [ ${#projects[@]} -eq 0 ]; then
echo "No test projects were found in test/component." >&2
exit 1
fi
for project in "${projects[@]}"; do
echo "Building component test project ${project}"
dotnet build "$project" --configuration Release --framework net10.0
echo "Running component tests with coverage and hang diagnostics for ${project}"
dotnet test "$project" \
--configuration Release \
--framework net10.0 \
--no-build \
--logger "GitHubActions;report-warnings=false" \
--logger "trx" \
--results-directory artifacts/test-results/component \
--verbosity detailed \
--blame-hang \
--blame-hang-timeout 2m \
--blame-hang-dump-type mini \
/p:CollectCoverage=true
done
- name: Dump docker logs on failure
if: failure()
run: |
echo "=== Docker containers ==="
docker ps -a || true
echo "=== Docker logs ==="
for container in $(docker ps -aq); do
echo "--- Logs for container $container ---"
docker logs "$container" 2>&1 | tail -100 || true
done
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v4
with:
name: component-coverage-reports
path: artifacts/coverage
- name: Upload component test diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: component-test-diagnostics
path: artifacts/test-results
coverage_report:
name: Generate coverage report
needs:
- test_unit_integration
- test_component
runs-on: ubuntu-latest
timeout-minutes: 10
if: always() && !cancelled() && needs.test_unit_integration.result == 'success' && needs.test_component.result == 'success'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.1xx
- name: Download coverage reports
uses: actions/download-artifact@v4
with:
pattern: '*-coverage-reports'
path: artifacts/coverage
merge-multiple: true
- name: Install ReportGenerator
run: dotnet tool install -g dotnet-reportgenerator-globaltool
- name: Generate HTML coverage report
run: |
reportgenerator \
"-reports:./artifacts/coverage/**/coverage.cobertura.xml" \
"-targetdir:./artifacts/coverage-report" \
"-reporttypes:Html;Cobertura;TextSummary" \
"-verbosity:Info"
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-reports
path: artifacts/coverage
- name: Upload Pages artifact
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')
uses: actions/upload-pages-artifact@v3
with:
path: './artifacts/coverage-report'
build:
name: Build packages
needs:
- test_unit_integration
- test_component
runs-on: ubuntu-latest
timeout-minutes: 30
if: ${{ github.event_name != 'pull_request' }}
steps:
- name: Extract branch name
run: |
BRANCH_NAME=${{ github.ref }} # e.g., refs/heads/main
BRANCH_NAME=${BRANCH_NAME#refs/heads/} # remove the refs/heads/ prefix
# Extract the last part after the last slash of the branch name, if any, e.g., feature/issue-123 -> issue-123 and use it as the version prefix.
PACKAGE_PREFIX=$(echo $BRANCH_NAME | rev | cut -d/ -f1 | rev | tr '_' '-')
# If the branch name is main, use the preview version. Otherwise, use the branch name as the version prefix.
if [[ "${BRANCH_NAME}" == "main" || "${BRANCH_NAME}" =~ ^develop/ || "${BRANCH_NAME}" =~ ^release/ ]]; then
PACKAGE_PREFIX="preview"
fi
echo "Ref: ${{ github.ref }}"
echo "Branch name: ${BRANCH_NAME}"
echo "Package prefix: ${PACKAGE_PREFIX}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> $GITHUB_ENV
echo "PACKAGE_PREFIX=${PACKAGE_PREFIX}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v4
- name: Verify commit exists in branch
run: |
if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased") ]]; then
git fetch --no-tags --prune origin +refs/heads/*:refs/remotes/origin/*
git branch --remote --contains | grep -E 'origin/(main|release/)'
else
git fetch --no-tags --prune origin +refs/heads/*:refs/remotes/origin/*
git branch --remote --contains | grep origin/${BRANCH_NAME}
fi
- name: Set VERSION variable
run: |
if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased") ]]; then
TAG_NAME=${{ github.ref }} # e.g., refs/tags/3.0.0
TAG_NAME=${TAG_NAME#refs/tags/} # remove the refs/tags/ prefix
echo "VERSION=${TAG_NAME}" >> $GITHUB_ENV
else
echo "VERSION=${{env.base_version}}-preview.${{github.run_number}}" >> $GITHUB_ENV
fi
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.x
- name: Compile+Pack
run: ./build.sh Compile+Pack --version ${VERSION} --analyseCode true
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: elsa-nuget-packages
path: packages/*nupkg
if: ${{ github.event_name == 'release' || github.event_name == 'push'}}
publish_preview_feedz:
name: Publish to feedz.io
needs: build
runs-on: ubuntu-latest
# 30 minutes against the push action's 20-minute deadline: the action stops
# starting pushes once its own deadline passes, so the runner never has to
# cancel a publish half-done. Move the two together.
timeout-minutes: 30
if: ${{ github.event_name == 'release' || github.event_name == 'push'}}
steps:
- name: Check out the push action
# Every third-party action in this job runs before a feed API key is handed
# to the local composite, in the same writable workspace, so each one is
# pinned to a full commit SHA. A mutable tag here could be retargeted to
# substitute the composite before it receives the credential.
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
sparse-checkout: .github/actions
fetch-depth: 1
- name: Download Packages
# Pinned for the reason given on the checkout above.
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: elsa-nuget-packages
- name: Publish to feedz.io
uses: ./.github/actions/push-nuget-packages
with:
feed-source: ${{ env.feedz_feed_source }}
api-key: ${{ secrets.FEEDZ_API_KEY }}
publish_nuget:
name: Publish release to nuget.org
needs: build
runs-on: ubuntu-latest
# 30 minutes against the push action's 20-minute deadline: the action stops
# starting pushes once its own deadline passes, so the runner never has to
# cancel a publish half-done. Move the two together.
timeout-minutes: 30
if: ${{ github.event.action == 'published' }}
steps:
- name: Check out the push action
# Every third-party action in this job runs before a feed API key is handed
# to the local composite, in the same writable workspace, so each one is
# pinned to a full commit SHA. A mutable tag here could be retargeted to
# substitute the composite before it receives the credential.
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
sparse-checkout: .github/actions
fetch-depth: 1
- name: Download Packages
# Pinned for the reason given on the checkout above.
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: elsa-nuget-packages
- name: Publish to nuget.org
uses: ./.github/actions/push-nuget-packages
with:
feed-source: ${{ env.nuget_feed_source }}
api-key: ${{ secrets.NUGET_API_KEY }}
deploy_coverage:
name: Deploy coverage to GitHub Pages
needs: coverage_report
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.1' || github.ref == 'refs/heads/release/3.6.1'
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4