Skip to content

Commit be22332

Browse files
committed
refactor: split release pipeline into retryable jobs with idempotent publish flow
Improve release consistency by isolating NuGet, attestation, tag, and GitHub release into dedicated jobs with gate logging, while moving NuGet push logic into a NUKE target for safer retries and clearer failure boundaries. Made-with: Cursor
1 parent 2b9bb61 commit be22332

3 files changed

Lines changed: 292 additions & 51 deletions

File tree

.github/workflows/ci.yml

Lines changed: 235 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -400,83 +400,190 @@ jobs:
400400
path: docs/.vitepress/dist
401401
if-no-files-found: error
402402

403-
release:
404-
name: Release
403+
release-gate:
404+
name: Release Gate
405405
needs: [resolve-version, build-and-test]
406406
if: needs.resolve-version.outputs.is_release == 'true'
407407
runs-on: ubuntu-latest
408-
timeout-minutes: 15
408+
timeout-minutes: 10
409409
environment: release
410410
permissions:
411-
contents: write
412-
attestations: write
411+
actions: read
412+
contents: read
413+
steps:
414+
- name: Log environment and reviewer status
415+
id: release-status
416+
shell: bash
417+
env:
418+
GH_TOKEN: ${{ github.token }}
419+
run: |
420+
set -euo pipefail
421+
REPO="${{ github.repository }}"
422+
RUN_ID="${{ github.run_id }}"
423+
ENVIRONMENT_NAME="release"
424+
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
425+
426+
APPROVALS_JSON="$(gh api "repos/$REPO/actions/runs/$RUN_ID/approvals" 2>/dev/null || echo "[]")"
427+
PENDING_JSON="$(gh api "repos/$REPO/actions/runs/$RUN_ID/pending_deployments" 2>/dev/null || echo "[]")"
428+
APPROVAL_JSON="$(echo "$APPROVALS_JSON" | jq --arg env "$ENVIRONMENT_NAME" '[.[] | select(any(.environments[]?; .name == $env))] | first // {}')"
429+
430+
APPROVER="$(echo "$APPROVAL_JSON" | jq -r '.user.login // "N/A"')"
431+
APPROVAL_STATE="$(echo "$APPROVAL_JSON" | jq -r '.state // "N/A"')"
432+
APPROVED_AT="$(echo "$APPROVAL_JSON" | jq -r '.updated_at // "N/A"')"
433+
APPROVAL_COMMENT="$(echo "$APPROVAL_JSON" | jq -r '.comment // "N/A"')"
434+
APPROVAL_COMMENT="${APPROVAL_COMMENT//$'\n'/ }"
435+
APPROVAL_COMMENT="${APPROVAL_COMMENT//|/\\|}"
436+
PENDING_COUNT="$(echo "$PENDING_JSON" | jq 'length')"
437+
438+
mkdir -p artifacts/release
439+
jq -n \
440+
--arg event "release_environment_status" \
441+
--arg timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
442+
--arg run_id "$RUN_ID" \
443+
--arg run_url "$RUN_URL" \
444+
--arg env "$ENVIRONMENT_NAME" \
445+
--arg approval_state "$APPROVAL_STATE" \
446+
--arg approver "$APPROVER" \
447+
--arg approval_comment "$APPROVAL_COMMENT" \
448+
--arg approved_at "$APPROVED_AT" \
449+
--arg tag "${{ needs.resolve-version.outputs.tag }}" \
450+
--arg sha "${{ needs.resolve-version.outputs.sha }}" \
451+
--argjson pending_count "$PENDING_COUNT" \
452+
'{
453+
event: $event,
454+
timestamp: $timestamp,
455+
run_id: $run_id,
456+
run_url: $run_url,
457+
environment: $env,
458+
approval: {
459+
state: $approval_state,
460+
reviewer: $approver,
461+
comment: $approval_comment,
462+
approved_at: $approved_at
463+
},
464+
pending_deployments_count: $pending_count,
465+
context: {
466+
tag: $tag,
467+
sha: $sha
468+
}
469+
}' > artifacts/release/release-status.json
470+
471+
echo "::group::Release Environment Status"
472+
cat artifacts/release/release-status.json
473+
echo "::endgroup::"
474+
475+
{
476+
echo "## Environment and Reviewer Status"
477+
echo ""
478+
echo "| Property | Value |"
479+
echo "|----------|-------|"
480+
echo "| **Environment** | \`$ENVIRONMENT_NAME\` |"
481+
echo "| **Approval State** | $APPROVAL_STATE |"
482+
echo "| **Approved By** | @$APPROVER |"
483+
echo "| **Approved At** | $APPROVED_AT |"
484+
echo "| **Approval Comment** | $APPROVAL_COMMENT |"
485+
echo "| **Pending Deployments** | $PENDING_COUNT |"
486+
echo "| **Run** | [#$RUN_ID]($RUN_URL) |"
487+
} >> "$GITHUB_STEP_SUMMARY"
488+
489+
- name: Upload release status artifact
490+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
491+
with:
492+
name: release-status
493+
path: artifacts/release/release-status.json
494+
if-no-files-found: error
495+
496+
release-nuget:
497+
name: Release NuGet
498+
needs: [resolve-version, build-and-test, release-gate]
499+
if: needs.resolve-version.outputs.is_release == 'true' && env.ENABLE_NUGET == 'true'
500+
runs-on: ubuntu-latest
501+
timeout-minutes: 15
502+
permissions:
503+
contents: read
413504
id-token: write
414-
concurrency:
415-
group: release-tag-${{ needs.resolve-version.outputs.tag }}
416-
cancel-in-progress: false
417505
steps:
418506
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
419507
with:
420508
ref: ${{ needs.resolve-version.outputs.sha }}
421-
fetch-depth: 0
422509

423510
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
424511
with:
425512
global-json-file: global.json
426513

427514
- name: Download packages
428-
if: env.ENABLE_NUGET == 'true'
429515
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
430516
with:
431517
name: packages
432518
path: artifacts/packages
433519

434-
- name: Download installers
435-
if: env.ENABLE_INSTALLERS == 'true'
436-
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
437-
with:
438-
path: artifacts/installers
439-
pattern: installer-*
440-
merge-multiple: true
441-
442520
- name: Mark build script executable
443521
run: chmod +x build.sh
444522

445523
- name: Verify release manifest
446-
if: env.ENABLE_NUGET == 'true'
447524
shell: bash
448525
run: ./build.sh ValidateReleaseManifest
449526

450527
- name: NuGet login (Trusted Publishing / OIDC)
451-
if: env.ENABLE_NUGET == 'true' && env.NUGET_USE_OIDC == 'true'
528+
if: env.NUGET_USE_OIDC == 'true'
452529
id: nuget-login
453530
uses: NuGet/login@d22cc5f58ff5b88bf9bd452535b4335137e24544 # v1.1.0
454531
with:
455532
user: ${{ vars.NUGET_USER || github.repository_owner }}
456533

457-
- name: Push to NuGet.org
458-
if: env.ENABLE_NUGET == 'true'
534+
- name: Resolve NuGet API key
459535
shell: bash
460536
env:
461-
API_KEY: ${{ env.NUGET_USE_OIDC == 'true' && steps.nuget-login.outputs.NUGET_API_KEY || secrets.NUGET_API_KEY }}
537+
OIDC_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }}
538+
SECRET_API_KEY: ${{ secrets.NUGET_API_KEY }}
462539
run: |
540+
set -euo pipefail
541+
API_KEY="$SECRET_API_KEY"
542+
if [ "$NUGET_USE_OIDC" = "true" ]; then
543+
API_KEY="$OIDC_API_KEY"
544+
fi
545+
463546
if [ -z "$API_KEY" ]; then
464547
echo "::error::NUGET_API_KEY is not available. Set the secret or enable OIDC."
465548
exit 1
466549
fi
467-
shopt -s nullglob
468-
PACKAGES=(artifacts/packages/*.nupkg)
469-
if [ ${#PACKAGES[@]} -eq 0 ]; then
470-
echo "::error::No .nupkg files found"
471-
exit 1
472-
fi
473-
for pkg in "${PACKAGES[@]}"; do
474-
echo "Pushing $pkg"
475-
dotnet nuget push "$pkg" \
476-
--api-key "$API_KEY" \
477-
--source https://api.nuget.org/v3/index.json \
478-
--skip-duplicate
479-
done
550+
551+
echo "::add-mask::$API_KEY"
552+
echo "NUGET_API_KEY=$API_KEY" >> "$GITHUB_ENV"
553+
554+
- name: Push packages to NuGet.org
555+
shell: bash
556+
run: ./build.sh PushNuGetPackages
557+
558+
release-attest:
559+
name: Release Attest
560+
needs: [resolve-version, build-and-test, release-gate]
561+
if: needs.resolve-version.outputs.is_release == 'true'
562+
runs-on: ubuntu-latest
563+
timeout-minutes: 15
564+
permissions:
565+
contents: read
566+
attestations: write
567+
id-token: write
568+
steps:
569+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
570+
with:
571+
ref: ${{ needs.resolve-version.outputs.sha }}
572+
573+
- name: Download packages
574+
if: env.ENABLE_NUGET == 'true'
575+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
576+
with:
577+
name: packages
578+
path: artifacts/packages
579+
580+
- name: Download installers
581+
if: env.ENABLE_INSTALLERS == 'true'
582+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
583+
with:
584+
path: artifacts/installers
585+
pattern: installer-*
586+
merge-multiple: true
480587

481588
- name: Generate SBOM
482589
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1
@@ -489,6 +596,7 @@ jobs:
489596
id: attest-subjects
490597
shell: bash
491598
run: |
599+
set -euo pipefail
492600
shopt -s nullglob
493601
SUBJECTS=""
494602
if [ "$ENABLE_NUGET" = "true" ]; then
@@ -519,39 +627,101 @@ jobs:
519627
subject-path: ${{ steps.attest-subjects.outputs.subjects }}
520628
sbom-path: sbom-spdx.json
521629

522-
- name: Create and push tag
630+
- name: Upload SBOM artifact
631+
if: hashFiles('sbom-spdx.json') != ''
632+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
633+
with:
634+
name: sbom
635+
path: sbom-spdx.json
636+
if-no-files-found: error
637+
638+
release-tag:
639+
name: Release Tag
640+
needs: [resolve-version, release-gate, release-nuget]
641+
if: needs.resolve-version.outputs.is_release == 'true' && (needs.release-nuget.result == 'success' || needs.release-nuget.result == 'skipped')
642+
runs-on: ubuntu-latest
643+
timeout-minutes: 10
644+
permissions:
645+
contents: write
646+
concurrency:
647+
group: release-tag-${{ needs.resolve-version.outputs.tag }}
648+
cancel-in-progress: false
649+
steps:
650+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
651+
with:
652+
ref: ${{ needs.resolve-version.outputs.sha }}
653+
fetch-depth: 0
654+
655+
- name: Create and push tag (idempotent)
523656
shell: bash
524657
run: |
658+
set -euo pipefail
525659
TAG="${{ needs.resolve-version.outputs.tag }}"
526660
SHA="${{ needs.resolve-version.outputs.sha }}"
527661
528662
git fetch --tags --force
529-
if git rev-parse "$TAG" >/dev/null 2>&1; then
530-
echo "::error::Tag $TAG already exists locally"
663+
664+
LOCAL_SHA="$(git rev-parse -q --verify "refs/tags/$TAG^{commit}" 2>/dev/null || true)"
665+
if [ -n "$LOCAL_SHA" ]; then
666+
if [ "$LOCAL_SHA" = "$SHA" ]; then
667+
echo "::notice::Tag $TAG already exists locally at expected SHA $SHA."
668+
exit 0
669+
fi
670+
echo "::error::Tag $TAG exists locally at $LOCAL_SHA but expected $SHA."
531671
exit 1
532672
fi
533-
if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then
534-
echo "::error::Tag $TAG already exists on remote"
673+
674+
REMOTE_SHA="$(git ls-remote --tags origin "refs/tags/$TAG^{}" | awk '{print $1}' | head -n1)"
675+
if [ -z "$REMOTE_SHA" ]; then
676+
REMOTE_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}' | head -n1)"
677+
fi
678+
679+
if [ -n "$REMOTE_SHA" ]; then
680+
if [ "$REMOTE_SHA" = "$SHA" ]; then
681+
echo "::notice::Tag $TAG already exists on remote at expected SHA $SHA."
682+
exit 0
683+
fi
684+
echo "::error::Tag $TAG exists on remote at $REMOTE_SHA but expected $SHA."
535685
exit 1
536686
fi
537687
538688
git tag "$TAG" "$SHA"
539689
git push origin "$TAG"
540690
echo "Created and pushed tag $TAG at $SHA"
541691
542-
- name: Create GitHub Release
692+
release-github:
693+
name: Release GitHub
694+
needs: [resolve-version, release-tag, release-attest]
695+
if: needs.resolve-version.outputs.is_release == 'true'
696+
runs-on: ubuntu-latest
697+
timeout-minutes: 10
698+
permissions:
699+
contents: write
700+
steps:
701+
- name: Download installers
702+
if: env.ENABLE_INSTALLERS == 'true'
703+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
704+
with:
705+
path: artifacts/installers
706+
pattern: installer-*
707+
merge-multiple: true
708+
709+
- name: Download SBOM artifact
710+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
711+
continue-on-error: true
712+
with:
713+
name: sbom
714+
path: .
715+
716+
- name: Create or update GitHub Release
543717
shell: bash
544718
env:
545719
GH_TOKEN: ${{ github.token }}
546720
run: |
721+
set -euo pipefail
547722
TAG="${{ needs.resolve-version.outputs.tag }}"
548723
RELEASE_NAME="dotnet.CI.template $TAG"
549724
550-
if gh release view "$TAG" >/dev/null 2>&1; then
551-
echo "::error::Release already exists for tag $TAG"
552-
exit 1
553-
fi
554-
555725
shopt -s nullglob
556726
RELEASE_ASSETS=()
557727
@@ -563,14 +733,28 @@ jobs:
563733
RELEASE_ASSETS+=(sbom-spdx.json)
564734
fi
565735
566-
gh release create "$TAG" "${RELEASE_ASSETS[@]}" \
567-
--title "$RELEASE_NAME" \
568-
--generate-notes
736+
if gh release view "$TAG" >/dev/null 2>&1; then
737+
echo "::notice::Release already exists for $TAG. Uploading missing assets."
738+
if [ ${#RELEASE_ASSETS[@]} -gt 0 ]; then
739+
gh release upload "$TAG" "${RELEASE_ASSETS[@]}" --clobber
740+
fi
741+
exit 0
742+
fi
743+
744+
if [ ${#RELEASE_ASSETS[@]} -gt 0 ]; then
745+
gh release create "$TAG" "${RELEASE_ASSETS[@]}" \
746+
--title "$RELEASE_NAME" \
747+
--generate-notes
748+
else
749+
gh release create "$TAG" \
750+
--title "$RELEASE_NAME" \
751+
--generate-notes
752+
fi
569753
echo "Created release $TAG with ${#RELEASE_ASSETS[@]} assets"
570754
571755
deploy-docs:
572756
name: Deploy Documentation
573-
needs: [resolve-version, release, build-docs]
757+
needs: [resolve-version, release-github, build-docs]
574758
if: needs.resolve-version.outputs.is_release == 'true' && needs.build-docs.outputs.has_docs == 'true'
575759
runs-on: ubuntu-latest
576760
timeout-minutes: 10

build/BuildTask.Parameters.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ partial class BuildTask
2626
[Parameter("Publish self-contained output in Publish target")]
2727
readonly bool SelfContained;
2828

29+
[Parameter("NuGet API key for PushNuGetPackages target. Defaults to NUGET_API_KEY environment variable.")]
30+
readonly string NuGetApiKey = string.Empty;
31+
2932
[Parameter("Minimum line coverage percentage (0-100). CoverageReport fails if below this threshold.")]
3033
readonly int CoverageThreshold = 90;
3134

0 commit comments

Comments
 (0)