@@ -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
0 commit comments