From 5552840d4940f46a4a5ba68137524cdcad30d80b Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 12 Aug 2026 18:10:24 -0500 Subject: [PATCH 1/8] Automate SDK releases --- .github/scripts/create-github-release.sh | 87 +++++++++++++++ .github/workflows/nuget-publish.yml | 37 +++++-- .github/workflows/release-publish.yml | 131 ++++++++++++++++++----- 3 files changed, 220 insertions(+), 35 deletions(-) create mode 100755 .github/scripts/create-github-release.sh diff --git a/.github/scripts/create-github-release.sh b/.github/scripts/create-github-release.sh new file mode 100755 index 00000000..b880aa39 --- /dev/null +++ b/.github/scripts/create-github-release.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +# Create the Git tag and GitHub release for an already-published SDK version. +# +# NuGet publication and smoke testing happen before this script runs. It is +# deliberately safe to rerun: matching GitHub state is accepted, while a tag or +# release that conflicts with the requested version and commit stops the run. + +set -euo pipefail + +readonly version="${1:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" +readonly release_commit="${2:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" +readonly prerelease="${3:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" + +: "${GH_TOKEN:?GH_TOKEN must authenticate GitHub CLI requests}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must identify the release repository}" +: "${RUNNER_TEMP:?RUNNER_TEMP must identify a temporary output directory}" + +readonly notable_changes="$RUNNER_TEMP/notable-changes.md" +readonly generated_notes="$RUNNER_TEMP/generated-notes.md" +readonly release_notes="$RUNNER_TEMP/release-notes.md" + +# Refuse to reuse a version tag from another commit. If the tag does not exist, +# `gh release create` will create it at the immutable release commit. +tag_args=(--target "$release_commit") +if git rev-parse --verify --quiet "refs/tags/$version" >/dev/null; then + tag_commit=$(git rev-list -n 1 "$version") + if [[ "$tag_commit" != "$release_commit" ]]; then + echo "Tag $version points to $tag_commit, expected $release_commit" >&2 + exit 1 + fi + tag_args=(--verify-tag) +fi + +# A successful rerun must not replace or silently modify an existing release. +# Accept only the public release shape this script itself would have created. +if release=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$version" 2>/dev/null); then + release_name=$(jq -r .name <<< "$release") + release_draft=$(jq -r .draft <<< "$release") + release_prerelease=$(jq -r .prerelease <<< "$release") + if [[ "$release_name" != "$version" || + "$release_draft" != false || + "$release_prerelease" != "$prerelease" ]]; then + echo "GitHub release $version exists with conflicting metadata" >&2 + exit 1 + fi + echo "GitHub release $version already exists at the expected tag" + exit 0 +fi + +# Preserve the curated Unreleased changelog text as the first section. The next +# numbered release heading marks its end; ordinary Added/Changed/etc. headings +# remain part of the selected content. +awk ' + /^## \[Unreleased\]$/ { found=1; next } + found && /^#{2,3} \[[0-9]/ { exit } + found { print } +' CHANGELOG.md > "$notable_changes" +if ! grep -q '[^[:space:]]' "$notable_changes"; then + echo "CHANGELOG.md has no Unreleased release notes" >&2 + exit 1 +fi + +# Append GitHub's contributor and comparison notes to the curated highlights. +gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$version" \ + -f target_commitish="$release_commit" \ + --jq .body > "$generated_notes" +{ + echo "## Notable Changes" + cat "$notable_changes" + echo + cat "$generated_notes" +} > "$release_notes" + +# Stable releases become latest; prereleases are explicitly marked and do not +# disturb the repository's latest stable release. +release_args=(--latest) +if [[ "$prerelease" == true ]]; then + release_args=(--prerelease) +fi + +gh release create "$version" \ + "${tag_args[@]}" \ + "${release_args[@]}" \ + --title "$version" \ + --notes-file "$release_notes" diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 8b1a9be2..3dcd303c 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -40,8 +40,17 @@ on: required: false type: string default: nuget-package + artifact-run-id: + description: "Workflow run containing the package artifact." + required: true + type: string + commit: + description: "Exact source commit used to build the package artifact." + required: true + type: string permissions: + actions: read contents: read jobs: @@ -49,20 +58,25 @@ jobs: runs-on: ubuntu-latest environment: ${{ inputs.environment }} permissions: + actions: read contents: read id-token: write # Required for Trusted Publishing OIDC token issuance steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false + ref: ${{ inputs.commit }} sparse-checkout: global.json sparse-checkout-cone-mode: false - name: Download NuGet artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: + github-token: ${{ github.token }} name: ${{ inputs.artifact-name }} path: ${{ github.workspace }}/nuget-package + run-id: ${{ inputs.artifact-run-id }} - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -104,27 +118,34 @@ jobs: # Flat-container is served from the same host as the V3 index. host="${INDEX_URL#https://}" host="${host%%/*}" - remaining="temporalio temporalio.extensions.diagnosticsource temporalio.extensions.hosting temporalio.extensions.opentelemetry" + remaining=( + temporalio + temporalio.extensions.aws.lambda + temporalio.extensions.aws.lambda.opentelemetry + temporalio.extensions.diagnosticsource + temporalio.extensions.hosting + temporalio.extensions.opentelemetry + ) deadline=$(( $(date +%s) + 1800 )) # 30 minutes while true; do - pending="" - for pkg in $remaining; do + pending=() + for pkg in "${remaining[@]}"; do if curl -fsSL "https://$host/v3-flatcontainer/$pkg/index.json" 2>/dev/null \ | jq -e --arg v "$VERSION" '.versions | index($v)' >/dev/null; then echo " $pkg $VERSION is available on $host" else - pending="$pending $pkg" + pending+=("$pkg") fi done - remaining="${pending# }" - [ -z "$remaining" ] && break + remaining=("${pending[@]}") + [ "${#remaining[@]}" -eq 0 ] && break if [ "$(date +%s)" -ge "$deadline" ]; then echo "Timed out on $host; still not available:" >&2 - printf ' - %s\n' $remaining >&2 + printf ' - %s\n' "${remaining[@]}" >&2 exit 1 fi echo "Pending on $host:" - printf ' - %s\n' $remaining + printf ' - %s\n' "${remaining[@]}" echo "Waiting for 15s..." sleep 15 done diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index bf731264..ea46f136 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -1,46 +1,91 @@ name: Release Publish -run-name: Release ${{ github.ref_name }} +run-name: Release from ${{ github.event.workflow_run.head_branch }}@${{ github.event.workflow_run.head_sha }} -# Automated NuGet publishing and smoke testing. +# A merged version change is the release authorization. The workflow publishes +# NuGet first, verifies the packages, and only then makes the GitHub release +# public. Rerunning a partially completed release is safe: NuGet pushes skip +# existing packages and the GitHub job accepts only the exact expected release. on: - workflow_dispatch: {} + workflow_run: + workflows: + - NuGet Package + types: + - completed + branches: + - main + - "releases/*" permissions: + actions: read contents: read -jobs: - build: - uses: ./.github/workflows/nuget-package.yml - permissions: - contents: read +concurrency: + group: sdk-dotnet-release-publication + cancel-in-progress: false - read-version: +jobs: + candidate: + if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: + actions: read contents: read outputs: - version: ${{ steps.read.outputs.version }} + artifact-run-id: ${{ steps.candidate.outputs.artifact-run-id }} + commit: ${{ steps.candidate.outputs.commit }} + prerelease: ${{ steps.candidate.outputs.prerelease }} + release: ${{ steps.candidate.outputs.release }} + version: ${{ steps.candidate.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.workflow_run.head_sha }} - - name: Read version - id: read + - name: Read release candidate + id: candidate + env: + ARTIFACT_RUN_ID: ${{ github.event.workflow_run.id }} + RELEASE_COMMIT: ${{ github.event.workflow_run.head_sha }} run: | - version=$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props) - if [ -z "$version" ]; then - echo "Could not read from Directory.Build.props" >&2 + set -euo pipefail + + read_version() { + sed -n 's|^[[:space:]]*\([^<]*\)[[:space:]]*$|\1|p' + } + + version=$(read_version < Directory.Build.props) + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then + echo "Directory.Build.props has an invalid release version: $version" >&2 exit 1 fi - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Releasing version: $version" + + base_commit=$(git rev-parse "$RELEASE_COMMIT^") + base_version=$(git show "$base_commit:Directory.Build.props" | read_version) + + { + echo "artifact-run-id=$ARTIFACT_RUN_ID" + echo "commit=$RELEASE_COMMIT" + echo "version=$version" + } >> "$GITHUB_OUTPUT" + if [[ "$version" == "$base_version" ]]; then + echo "release=false" >> "$GITHUB_OUTPUT" + echo "The package version remains $version; there is no release to publish." + else + echo "release=true" >> "$GITHUB_OUTPUT" + [[ "$version" == *-* ]] && prerelease=true || prerelease=false + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + echo "Releasing $version from $RELEASE_COMMIT (previous version: $base_version)" + fi publish-int: - needs: - - build - - read-version + needs: candidate + if: needs.candidate.outputs.release == 'true' permissions: + actions: read contents: read id-token: write uses: ./.github/workflows/nuget-publish.yml @@ -49,12 +94,14 @@ jobs: index-url: https://apiint.nugettest.org/v3/index.json token-service-url: https://int.nugettest.org/api/v2/token audience: https://int.nugettest.org - version: ${{ needs.read-version.outputs.version }} + artifact-run-id: ${{ needs.candidate.outputs.artifact-run-id }} + commit: ${{ needs.candidate.outputs.commit }} + version: ${{ needs.candidate.outputs.version }} smoke-int: needs: - publish-int - - read-version + - candidate runs-on: windows-latest permissions: contents: read @@ -62,20 +109,23 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false + ref: ${{ needs.candidate.outputs.commit }} submodules: recursive - name: Smoke test against int.nugettest.org uses: ./.github/actions/smoke-test with: # New package resolves from int; transitive/third-party deps from prod. package-source: "https://apiint.nugettest.org/v3/index.json;https://api.nuget.org/v3/index.json" - version: ${{ needs.read-version.outputs.version }} + version: ${{ needs.candidate.outputs.version }} run-dotnet-framework: true publish-prod: needs: - smoke-int - - read-version + - candidate permissions: + actions: read contents: read id-token: write uses: ./.github/workflows/nuget-publish.yml @@ -83,12 +133,14 @@ jobs: environment: nugetprod index-url: https://api.nuget.org/v3/index.json token-service-url: https://www.nuget.org/api/v2/token - version: ${{ needs.read-version.outputs.version }} + artifact-run-id: ${{ needs.candidate.outputs.artifact-run-id }} + commit: ${{ needs.candidate.outputs.commit }} + version: ${{ needs.candidate.outputs.version }} smoke-prod: needs: - publish-prod - - read-version + - candidate runs-on: windows-latest permissions: contents: read @@ -96,10 +148,35 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false + ref: ${{ needs.candidate.outputs.commit }} submodules: recursive - name: Smoke test against nuget.org uses: ./.github/actions/smoke-test with: package-source: "https://api.nuget.org/v3/index.json" - version: ${{ needs.read-version.outputs.version }} + version: ${{ needs.candidate.outputs.version }} run-dotnet-framework: true + + github-release: + needs: + - candidate + - smoke-prod + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.candidate.outputs.commit }} + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + PRERELEASE: ${{ needs.candidate.outputs.prerelease }} + RELEASE_COMMIT: ${{ needs.candidate.outputs.commit }} + VERSION: ${{ needs.candidate.outputs.version }} + run: .github/scripts/create-github-release.sh "$VERSION" "$RELEASE_COMMIT" "$PRERELEASE" From 56fe6cfb94bb9db9b615c0d8ebea1c398fbd336b Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 19 Aug 2026 12:11:51 -0500 Subject: [PATCH 2/8] Wait for GCP package publication --- .github/workflows/nuget-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 3dcd303c..0abc729c 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -123,6 +123,7 @@ jobs: temporalio.extensions.aws.lambda temporalio.extensions.aws.lambda.opentelemetry temporalio.extensions.diagnosticsource + temporalio.extensions.gcp.cloudrun.opentelemetry temporalio.extensions.hosting temporalio.extensions.opentelemetry ) From 2f369de22ed6039ad05b516ea063403de31dbb8e Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 19 Aug 2026 12:39:43 -0500 Subject: [PATCH 3/8] Harden release retries --- .github/scripts/create-github-release.sh | 195 +++++++++++++------- .github/scripts/test-release-automation.sh | 86 +++++++++ .github/scripts/verify-nuget-publication.sh | 155 ++++++++++++++++ .github/workflows/ci.yml | 11 ++ .github/workflows/nuget-publish.yml | 58 ++---- .github/workflows/release-publish.yml | 5 +- 6 files changed, 400 insertions(+), 110 deletions(-) create mode 100755 .github/scripts/test-release-automation.sh create mode 100755 .github/scripts/verify-nuget-publication.sh diff --git a/.github/scripts/create-github-release.sh b/.github/scripts/create-github-release.sh index b880aa39..07566633 100755 --- a/.github/scripts/create-github-release.sh +++ b/.github/scripts/create-github-release.sh @@ -8,80 +8,135 @@ set -euo pipefail -readonly version="${1:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" -readonly release_commit="${2:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" -readonly prerelease="${3:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" - -: "${GH_TOKEN:?GH_TOKEN must authenticate GitHub CLI requests}" -: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must identify the release repository}" -: "${RUNNER_TEMP:?RUNNER_TEMP must identify a temporary output directory}" - -readonly notable_changes="$RUNNER_TEMP/notable-changes.md" -readonly generated_notes="$RUNNER_TEMP/generated-notes.md" -readonly release_notes="$RUNNER_TEMP/release-notes.md" - -# Refuse to reuse a version tag from another commit. If the tag does not exist, -# `gh release create` will create it at the immutable release commit. -tag_args=(--target "$release_commit") -if git rev-parse --verify --quiet "refs/tags/$version" >/dev/null; then - tag_commit=$(git rev-list -n 1 "$version") - if [[ "$tag_commit" != "$release_commit" ]]; then - echo "Tag $version points to $tag_commit, expected $release_commit" >&2 - exit 1 +version_is_newer() { + local candidate_version=$1 + local current_version=${2#v} + local -a candidate_parts current_parts + local index candidate_part current_part + + [[ "$candidate_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + [[ "$current_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + IFS=. read -r -a candidate_parts <<< "$candidate_version" + IFS=. read -r -a current_parts <<< "$current_version" + for index in 0 1 2; do + candidate_part=$((10#${candidate_parts[$index]})) + current_part=$((10#${current_parts[$index]})) + (( candidate_part > current_part )) && return 0 + (( candidate_part < current_part )) && return 1 + done + return 1 +} + +select_release_args() { + local version=$1 + local prerelease=$2 + local latest_release latest_error latest_tag + + if [[ "$prerelease" == true ]]; then + release_args=(--prerelease --latest=false) + return fi - tag_args=(--verify-tag) -fi + if [[ "$prerelease" != false ]]; then + echo "PRERELEASE must be true or false, got: $prerelease" >&2 + return 1 + fi + + latest_error="$RUNNER_TEMP/latest-release-error" + if latest_release=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" 2> "$latest_error"); then + latest_tag=$(jq -r .tag_name <<< "$latest_release") + if version_is_newer "$version" "$latest_tag"; then + release_args=(--latest) + else + # A delayed retry of an older version must not replace a newer release as + # the repository's latest stable release. + release_args=(--latest=false) + fi + elif grep -q '(HTTP 404)' "$latest_error"; then + release_args=(--latest) + else + cat "$latest_error" >&2 + echo "Unable to determine the repository's latest release" >&2 + return 1 + fi +} + +main() { + local version="${1:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" + local release_commit="${2:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" + local prerelease="${3:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" + local generated_notes notable_changes release release_draft release_name + local release_notes release_prerelease tag_commit + local -a release_args tag_args + + : "${GH_TOKEN:?GH_TOKEN must authenticate GitHub CLI requests}" + : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must identify the release repository}" + : "${RUNNER_TEMP:?RUNNER_TEMP must identify a temporary output directory}" -# A successful rerun must not replace or silently modify an existing release. -# Accept only the public release shape this script itself would have created. -if release=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$version" 2>/dev/null); then - release_name=$(jq -r .name <<< "$release") - release_draft=$(jq -r .draft <<< "$release") - release_prerelease=$(jq -r .prerelease <<< "$release") - if [[ "$release_name" != "$version" || - "$release_draft" != false || - "$release_prerelease" != "$prerelease" ]]; then - echo "GitHub release $version exists with conflicting metadata" >&2 + notable_changes="$RUNNER_TEMP/notable-changes.md" + generated_notes="$RUNNER_TEMP/generated-notes.md" + release_notes="$RUNNER_TEMP/release-notes.md" + + # Refuse to reuse a version tag from another commit. If the tag does not + # exist, `gh release create` creates it at the immutable release commit. + tag_args=(--target "$release_commit") + if git rev-parse --verify --quiet "refs/tags/$version" >/dev/null; then + tag_commit=$(git rev-list -n 1 "$version") + if [[ "$tag_commit" != "$release_commit" ]]; then + echo "Tag $version points to $tag_commit, expected $release_commit" >&2 + exit 1 + fi + tag_args=(--verify-tag) + fi + + # A successful rerun must not replace or silently modify an existing release. + # Accept only the public release shape this script itself would have created. + if release=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$version" 2>/dev/null); then + release_name=$(jq -r .name <<< "$release") + release_draft=$(jq -r .draft <<< "$release") + release_prerelease=$(jq -r .prerelease <<< "$release") + if [[ "$release_name" != "$version" || + "$release_draft" != false || + "$release_prerelease" != "$prerelease" ]]; then + echo "GitHub release $version exists with conflicting metadata" >&2 + exit 1 + fi + echo "GitHub release $version already exists at the expected tag" + exit 0 + fi + + # Preserve the curated Unreleased changelog text as the first section. The + # next numbered release heading marks its end; ordinary Added/Changed/etc. + # headings remain part of the selected content. + awk ' + /^## \[Unreleased\]$/ { found=1; next } + found && /^#{2,3} \[[0-9]/ { exit } + found { print } + ' CHANGELOG.md > "$notable_changes" + if ! grep -q '[^[:space:]]' "$notable_changes"; then + echo "CHANGELOG.md has no Unreleased release notes" >&2 exit 1 fi - echo "GitHub release $version already exists at the expected tag" - exit 0 -fi -# Preserve the curated Unreleased changelog text as the first section. The next -# numbered release heading marks its end; ordinary Added/Changed/etc. headings -# remain part of the selected content. -awk ' - /^## \[Unreleased\]$/ { found=1; next } - found && /^#{2,3} \[[0-9]/ { exit } - found { print } -' CHANGELOG.md > "$notable_changes" -if ! grep -q '[^[:space:]]' "$notable_changes"; then - echo "CHANGELOG.md has no Unreleased release notes" >&2 - exit 1 -fi + # Append GitHub's contributor and comparison notes to the curated highlights. + gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$version" \ + -f target_commitish="$release_commit" \ + --jq .body > "$generated_notes" + { + echo "## Notable Changes" + cat "$notable_changes" + echo + cat "$generated_notes" + } > "$release_notes" -# Append GitHub's contributor and comparison notes to the curated highlights. -gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ - -f tag_name="$version" \ - -f target_commitish="$release_commit" \ - --jq .body > "$generated_notes" -{ - echo "## Notable Changes" - cat "$notable_changes" - echo - cat "$generated_notes" -} > "$release_notes" - -# Stable releases become latest; prereleases are explicitly marked and do not -# disturb the repository's latest stable release. -release_args=(--latest) -if [[ "$prerelease" == true ]]; then - release_args=(--prerelease) -fi + select_release_args "$version" "$prerelease" + gh release create "$version" \ + "${tag_args[@]}" \ + "${release_args[@]}" \ + --title "$version" \ + --notes-file "$release_notes" +} -gh release create "$version" \ - "${tag_args[@]}" \ - "${release_args[@]}" \ - --title "$version" \ - --notes-file "$release_notes" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/.github/scripts/test-release-automation.sh b/.github/scripts/test-release-automation.sh new file mode 100755 index 00000000..257f71c4 --- /dev/null +++ b/.github/scripts/test-release-automation.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# Exercise the retry decisions that cannot safely be tested against real NuGet +# or GitHub releases. All external state is represented by temporary fixtures +# and a mocked GitHub CLI response. + +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +readonly script_dir +# The paths are resolved from this script so the test works from any directory. +# shellcheck disable=SC1091 +source "$script_dir/create-github-release.sh" +# shellcheck disable=SC1091 +source "$script_dir/verify-nuget-publication.sh" + +test_dir=$(mktemp -d) +readonly test_dir +trap 'rm -rf "$test_dir"' EXIT +export GITHUB_REPOSITORY=temporalio/sdk-dotnet +export RUNNER_TEMP="$test_dir" +declare -a release_args + +assert_args() { + local expected=$1 + local actual + actual=$(printf '%s ' "${release_args[@]}") + [[ "${actual% }" == "$expected" ]] || { + echo "Expected release arguments '$expected', got '${actual% }'" >&2 + exit 1 + } +} + +gh() { + if [[ "$MOCK_LATEST" == 404 ]]; then + echo 'gh: Not Found (HTTP 404)' >&2 + return 1 + fi + printf '{"tag_name":"%s"}\n' "$MOCK_LATEST" +} + +MOCK_LATEST=1.2.2 +select_release_args 1.2.3 false +assert_args --latest + +MOCK_LATEST=1.2.4 +select_release_args 1.2.3 false +assert_args --latest=false + +MOCK_LATEST=404 +select_release_args 1.2.3 false +assert_args --latest + +MOCK_LATEST=1.2.2 +select_release_args 1.2.3-beta.1 true +assert_args '--prerelease --latest=false' + +mkdir -p "$test_dir/local" "$test_dir/package-content" "$test_dir/signed-content" +printf 'candidate package content\n' > "$test_dir/package-content/content.txt" +(cd "$test_dir/package-content" && zip -q "$test_dir/local/package.nupkg" content.txt) +cp "$test_dir/local/package.nupkg" "$test_dir/unsigned-published.nupkg" +verify_published_package \ + "$test_dir/local/package.nupkg" "$test_dir/unsigned-published.nupkg" "$test_dir" + +content_hash=$(openssl dgst -sha256 -binary "$test_dir/local/package.nupkg" | openssl base64 -A) +printf 'Version:1\n\n2.16.840.1.101.3.4.2.1-Hash:%s\n' "$content_hash" \ + > "$test_dir/signature-content.txt" +openssl req -x509 -newkey rsa:2048 -nodes -subj /CN=release-test \ + -keyout "$test_dir/key.pem" -out "$test_dir/cert.pem" -days 1 2>/dev/null +openssl cms -sign -binary -nodetach -in "$test_dir/signature-content.txt" \ + -signer "$test_dir/cert.pem" -inkey "$test_dir/key.pem" -outform DER \ + -out "$test_dir/signed-content/.signature.p7s" +cp "$test_dir/local/package.nupkg" "$test_dir/signed-published.nupkg" +(cd "$test_dir/signed-content" && zip -q "$test_dir/signed-published.nupkg" .signature.p7s) +verify_published_package \ + "$test_dir/local/package.nupkg" "$test_dir/signed-published.nupkg" "$test_dir" + +printf 'different package content\n' > "$test_dir/package-content/content.txt" +(cd "$test_dir/package-content" && zip -q "$test_dir/local/conflict.nupkg" content.txt) +if verify_published_package \ + "$test_dir/local/conflict.nupkg" "$test_dir/signed-published.nupkg" "$test_dir"; then + echo "A conflicting published package was incorrectly accepted" >&2 + exit 1 +fi + +echo "Release automation tests passed" diff --git a/.github/scripts/verify-nuget-publication.sh b/.github/scripts/verify-nuget-publication.sh new file mode 100755 index 00000000..234ae169 --- /dev/null +++ b/.github/scripts/verify-nuget-publication.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +# Wait for every release package to reach a NuGet V3 flat container and verify +# that each published package came from the exact workflow artifact being +# released. This makes retries safe: an existing version is accepted only when +# its immutable content matches the candidate artifact. + +set -euo pipefail + +readonly package_ids=( + temporalio + temporalio.extensions.aws.lambda + temporalio.extensions.aws.lambda.opentelemetry + temporalio.extensions.diagnosticsource + temporalio.extensions.gcp.cloudrun.opentelemetry + temporalio.extensions.hosting + temporalio.extensions.opentelemetry +) + +package_content_hash() { + local package=$1 + local signature_file=$2 + local signature_content hash_oid hash_algorithm expected_hash + local -a hash_lines + + unzip -p "$package" .signature.p7s > "$signature_file" + signature_content=$(openssl cms -verify -binary -inform DER -noverify \ + -in "$signature_file" 2>/dev/null) + mapfile -t hash_lines < <( + sed -n 's/^[[:space:]]*\([0-9.]*\)-Hash:\([^[:space:]]*\)[[:space:]]*$/\1 \2/p' \ + <<< "$signature_content" + ) + if [[ ${#hash_lines[@]} -ne 1 ]]; then + echo "Published package has an invalid NuGet repository signature" >&2 + return 1 + fi + read -r hash_oid expected_hash <<< "${hash_lines[0]}" + case "$hash_oid" in + 2.16.840.1.101.3.4.2.1) hash_algorithm=sha256 ;; + 2.16.840.1.101.3.4.2.2) hash_algorithm=sha384 ;; + 2.16.840.1.101.3.4.2.3) hash_algorithm=sha512 ;; + *) + echo "Published package uses an unsupported content hash OID: $hash_oid" >&2 + return 1 + ;; + esac + printf '%s %s\n' "$hash_algorithm" "$expected_hash" +} + +verify_published_package() { + local local_package=$1 + local published_package=$2 + local scratch_dir=$3 + local actual_hash expected_hash hash_algorithm signature_file + + unzip -tqq "$local_package" >/dev/null + unzip -tqq "$published_package" >/dev/null + if unzip -Z1 "$local_package" | grep -Fxq .signature.p7s; then + echo "The workflow artifact is already signed; cannot derive its unsigned content hash" >&2 + return 1 + fi + + if unzip -Z1 "$published_package" | grep -Fxq .signature.p7s; then + signature_file="$scratch_dir/published-signature.p7s" + read -r hash_algorithm expected_hash < <( + package_content_hash "$published_package" "$signature_file" + ) + actual_hash=$(openssl dgst "-$hash_algorithm" -binary "$local_package" | openssl base64 -A) + [[ "$actual_hash" == "$expected_hash" ]] + else + # Test galleries may not repository-sign packages. In that case the bytes + # themselves must be identical to the artifact that was pushed. + cmp -s "$local_package" "$published_package" + fi +} + +find_local_package() { + local artifact_dir=$1 + local package_id=$2 + local version=$3 + local -a matches + + mapfile -t matches < <( + find "$artifact_dir" -type f -iname "$package_id.$version.nupkg" ! -iname '*.snupkg' + ) + if [[ ${#matches[@]} -ne 1 ]]; then + echo "Expected exactly one artifact for $package_id $version, found ${#matches[@]}" >&2 + return 1 + fi + printf '%s\n' "${matches[0]}" +} + +main() { + local artifact_dir="${1:?Usage: verify-nuget-publication.sh ARTIFACT_DIR INDEX_URL VERSION}" + local index_url="${2:?Usage: verify-nuget-publication.sh ARTIFACT_DIR INDEX_URL VERSION}" + local version="${3:?Usage: verify-nuget-publication.sh ARTIFACT_DIR INDEX_URL VERSION}" + local host package_id package_url local_package published_package + local deadline=${NUGET_WAIT_DEADLINE_SECONDS:-1800} + local interval=${NUGET_WAIT_INTERVAL_SECONDS:-15} + local scratch_dir + local -a remaining pending + + [[ "$index_url" =~ ^https://[^/]+/ ]] || { + echo "NuGet index URL must use HTTPS and include a path: $index_url" >&2 + exit 1 + } + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] || { + echo "Invalid NuGet version: $version" >&2 + exit 1 + } + + # Both galleries used by this workflow expose the flat container on the V3 + # index host. Package IDs and versions are lowercase in flat-container URLs. + host="${index_url#https://}" + host="${host%%/*}" + version=${version,,} + scratch_dir=$(mktemp -d) + trap 'rm -rf "$scratch_dir"' EXIT + remaining=("${package_ids[@]}") + deadline=$(( $(date +%s) + deadline )) + + while (( ${#remaining[@]} > 0 )); do + pending=() + for package_id in "${remaining[@]}"; do + package_url="https://$host/v3-flatcontainer/$package_id/$version/$package_id.$version.nupkg" + published_package="$scratch_dir/$package_id.nupkg" + if ! curl -fsSL "$package_url" -o "$published_package" 2>/dev/null; then + pending+=("$package_id") + continue + fi + local_package=$(find_local_package "$artifact_dir" "$package_id" "$version") + if ! verify_published_package "$local_package" "$published_package" "$scratch_dir"; then + echo "Published $package_id $version does not match the workflow artifact" >&2 + exit 1 + fi + echo " $package_id $version is available and matches the workflow artifact" + done + remaining=("${pending[@]}") + (( ${#remaining[@]} == 0 )) && break + if (( $(date +%s) >= deadline )); then + echo "Timed out on $host; still not available:" >&2 + printf ' - %s\n' "${remaining[@]}" >&2 + exit 1 + fi + echo "Pending on $host:" + printf ' - %s\n' "${remaining[@]}" + echo "Waiting for ${interval}s..." + sleep "$interval" + done + echo "All packages $version are available on $host and match the workflow artifact" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4da81325..19d7b188 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,17 @@ permissions: contents: read jobs: + release-automation-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Test release automation + run: .github/scripts/test-release-automation.sh + build-lint-test: env: RestoreLockedMode: true diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 0abc729c..998a5649 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -107,47 +107,27 @@ jobs: needs: publish runs-on: ubuntu-latest permissions: + actions: read contents: read steps: - - name: Wait for packages to be available + - name: Checkout publication verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ inputs.commit }} + sparse-checkout: .github/scripts/verify-nuget-publication.sh + sparse-checkout-cone-mode: false + + - name: Download NuGet artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + github-token: ${{ github.token }} + name: ${{ inputs.artifact-name }} + path: ${{ github.workspace }}/nuget-package + run-id: ${{ inputs.artifact-run-id }} + + - name: Wait for and verify packages env: VERSION: ${{ inputs.version }} INDEX_URL: ${{ inputs.index-url }} - run: | - set -euo pipefail - # Flat-container is served from the same host as the V3 index. - host="${INDEX_URL#https://}" - host="${host%%/*}" - remaining=( - temporalio - temporalio.extensions.aws.lambda - temporalio.extensions.aws.lambda.opentelemetry - temporalio.extensions.diagnosticsource - temporalio.extensions.gcp.cloudrun.opentelemetry - temporalio.extensions.hosting - temporalio.extensions.opentelemetry - ) - deadline=$(( $(date +%s) + 1800 )) # 30 minutes - while true; do - pending=() - for pkg in "${remaining[@]}"; do - if curl -fsSL "https://$host/v3-flatcontainer/$pkg/index.json" 2>/dev/null \ - | jq -e --arg v "$VERSION" '.versions | index($v)' >/dev/null; then - echo " $pkg $VERSION is available on $host" - else - pending+=("$pkg") - fi - done - remaining=("${pending[@]}") - [ "${#remaining[@]}" -eq 0 ] && break - if [ "$(date +%s)" -ge "$deadline" ]; then - echo "Timed out on $host; still not available:" >&2 - printf ' - %s\n' "${remaining[@]}" >&2 - exit 1 - fi - echo "Pending on $host:" - printf ' - %s\n' "${remaining[@]}" - echo "Waiting for 15s..." - sleep 15 - done - echo "All packages $VERSION available on $host" + run: .github/scripts/verify-nuget-publication.sh "$GITHUB_WORKSPACE/nuget-package" "$INDEX_URL" "$VERSION" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index ea46f136..3844ba2a 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -21,7 +21,10 @@ permissions: contents: read concurrency: - group: sdk-dotnet-release-publication + # GitHub keeps at most one pending run per concurrency group. Keying this to + # the immutable candidate prevents a later version from discarding an older + # version that is still waiting for an environment approval. + group: sdk-dotnet-release-${{ github.event.workflow_run.head_sha }} cancel-in-progress: false jobs: From b891a91a3e57d2efbd9afa7648399d77d2902601 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 19 Aug 2026 12:46:11 -0500 Subject: [PATCH 4/8] Make latest release selection race-safe --- .github/scripts/create-github-release.sh | 85 +++++++++++++++------- .github/scripts/test-release-automation.sh | 50 +++++-------- 2 files changed, 77 insertions(+), 58 deletions(-) diff --git a/.github/scripts/create-github-release.sh b/.github/scripts/create-github-release.sh index 07566633..293ff1c8 100755 --- a/.github/scripts/create-github-release.sh +++ b/.github/scripts/create-github-release.sh @@ -27,37 +27,57 @@ version_is_newer() { return 1 } -select_release_args() { - local version=$1 - local prerelease=$2 - local latest_release latest_error latest_tag - - if [[ "$prerelease" == true ]]; then - release_args=(--prerelease --latest=false) - return - fi - if [[ "$prerelease" != false ]]; then - echo "PRERELEASE must be true or false, got: $prerelease" >&2 +select_latest_release() { + local candidate_version=$1 + shift + local entry release_id release_tag release_version + + latest_release_id= + latest_release_tag= + latest_release_version= + candidate_found=false + for entry in "$@"; do + IFS=$'\t' read -r release_id release_tag <<< "$entry" + [[ "$release_tag" == "$candidate_version" ]] && candidate_found=true + release_version=${release_tag#v} + [[ "$release_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue + if [[ -z "$latest_release_id" ]] || \ + version_is_newer "$release_version" "$latest_release_version"; then + latest_release_id=$release_id + latest_release_tag=$release_tag + latest_release_version=$release_version + fi + done + if [[ "$candidate_found" != true || -z "$latest_release_id" ]]; then + echo "Unable to find the new release while selecting the latest stable version" >&2 return 1 fi +} - latest_error="$RUNNER_TEMP/latest-release-error" - if latest_release=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" 2> "$latest_error"); then - latest_tag=$(jq -r .tag_name <<< "$latest_release") - if version_is_newer "$version" "$latest_tag"; then - release_args=(--latest) - else - # A delayed retry of an older version must not replace a newer release as - # the repository's latest stable release. - release_args=(--latest=false) - fi - elif grep -q '(HTTP 404)' "$latest_error"; then - release_args=(--latest) - else - cat "$latest_error" >&2 - echo "Unable to determine the repository's latest release" >&2 +reconcile_latest_release() { + local candidate_version=$1 + local prerelease=$2 + local stable_release_data + local -a stable_releases + + [[ "$prerelease" == true ]] && return + [[ "$prerelease" == false ]] || { + echo "PRERELEASE must be true or false, got: $prerelease" >&2 return 1 - fi + } + + stable_release_data=$(gh api --paginate \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | [.id, .tag_name] | @tsv') + mapfile -t stable_releases <<< "$stable_release_data" + select_latest_release "$candidate_version" "${stable_releases[@]}" + + # Every concurrent release independently selects the same highest stable + # version after it becomes visible. The release created last also reconciles + # last, so completion order cannot leave an older version marked as latest. + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$latest_release_id" \ + -f make_latest=true --silent + echo "GitHub release $latest_release_tag is the latest stable release" } main() { @@ -71,6 +91,10 @@ main() { : "${GH_TOKEN:?GH_TOKEN must authenticate GitHub CLI requests}" : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must identify the release repository}" : "${RUNNER_TEMP:?RUNNER_TEMP must identify a temporary output directory}" + [[ "$prerelease" == true || "$prerelease" == false ]] || { + echo "PRERELEASE must be true or false, got: $prerelease" >&2 + exit 1 + } notable_changes="$RUNNER_TEMP/notable-changes.md" generated_notes="$RUNNER_TEMP/generated-notes.md" @@ -101,6 +125,7 @@ main() { exit 1 fi echo "GitHub release $version already exists at the expected tag" + reconcile_latest_release "$version" "$prerelease" exit 0 fi @@ -129,12 +154,16 @@ main() { cat "$generated_notes" } > "$release_notes" - select_release_args "$version" "$prerelease" + # Defer the latest marker until after creation so concurrent versions can + # reconcile it deterministically instead of racing read-then-create calls. + release_args=(--latest=false) + [[ "$prerelease" == true ]] && release_args=(--prerelease --latest=false) gh release create "$version" \ "${tag_args[@]}" \ "${release_args[@]}" \ --title "$version" \ --notes-file "$release_notes" + reconcile_latest_release "$version" "$prerelease" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/.github/scripts/test-release-automation.sh b/.github/scripts/test-release-automation.sh index 257f71c4..5325cb7e 100755 --- a/.github/scripts/test-release-automation.sh +++ b/.github/scripts/test-release-automation.sh @@ -19,41 +19,31 @@ readonly test_dir trap 'rm -rf "$test_dir"' EXIT export GITHUB_REPOSITORY=temporalio/sdk-dotnet export RUNNER_TEMP="$test_dir" -declare -a release_args +declare latest_release_id latest_release_tag -assert_args() { - local expected=$1 - local actual - actual=$(printf '%s ' "${release_args[@]}") - [[ "${actual% }" == "$expected" ]] || { - echo "Expected release arguments '$expected', got '${actual% }'" >&2 - exit 1 - } +select_latest_release 1.2.3 \ + $'101\t1.2.2' \ + $'102\t1.2.3' \ + $'103\t1.3.0-beta.1' \ + $'104\tv1.1.9' +[[ "$latest_release_id" == 102 && "$latest_release_tag" == 1.2.3 ]] || { + echo "The highest stable release was not selected" >&2 + exit 1 } -gh() { - if [[ "$MOCK_LATEST" == 404 ]]; then - echo 'gh: Not Found (HTTP 404)' >&2 - return 1 - fi - printf '{"tag_name":"%s"}\n' "$MOCK_LATEST" +# A later stable release must win even when the older candidate finishes last. +select_latest_release 1.2.3 \ + $'201\t1.2.3' \ + $'202\t1.2.4' +[[ "$latest_release_id" == 202 && "$latest_release_tag" == 1.2.4 ]] || { + echo "An older retry would incorrectly replace a newer latest release" >&2 + exit 1 } -MOCK_LATEST=1.2.2 -select_release_args 1.2.3 false -assert_args --latest - -MOCK_LATEST=1.2.4 -select_release_args 1.2.3 false -assert_args --latest=false - -MOCK_LATEST=404 -select_release_args 1.2.3 false -assert_args --latest - -MOCK_LATEST=1.2.2 -select_release_args 1.2.3-beta.1 true -assert_args '--prerelease --latest=false' +if select_latest_release 1.2.3 $'301\t1.2.4' 2>/dev/null; then + echo "Latest selection accepted a listing that omitted the new release" >&2 + exit 1 +fi mkdir -p "$test_dir/local" "$test_dir/package-content" "$test_dir/signed-content" printf 'candidate package content\n' > "$test_dir/package-content/content.txt" From 16d993ad4e5e89f245578d84d4ca68e8e902650c Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 19 Aug 2026 12:47:57 -0500 Subject: [PATCH 5/8] Verify symbol package publication --- .github/scripts/verify-nuget-publication.sh | 37 ++++++++++++++++----- .github/workflows/nuget-publish.yml | 12 ++++++- .github/workflows/release-publish.yml | 2 ++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/scripts/verify-nuget-publication.sh b/.github/scripts/verify-nuget-publication.sh index 234ae169..137a8983 100755 --- a/.github/scripts/verify-nuget-publication.sh +++ b/.github/scripts/verify-nuget-publication.sh @@ -78,23 +78,27 @@ find_local_package() { local artifact_dir=$1 local package_id=$2 local version=$3 + local extension=$4 local -a matches mapfile -t matches < <( - find "$artifact_dir" -type f -iname "$package_id.$version.nupkg" ! -iname '*.snupkg' + find "$artifact_dir" -type f -iname "$package_id.$version.$extension" ) if [[ ${#matches[@]} -ne 1 ]]; then - echo "Expected exactly one artifact for $package_id $version, found ${#matches[@]}" >&2 + echo "Expected exactly one $extension artifact for $package_id $version, found ${#matches[@]}" >&2 return 1 fi printf '%s\n' "${matches[0]}" } main() { - local artifact_dir="${1:?Usage: verify-nuget-publication.sh ARTIFACT_DIR INDEX_URL VERSION}" - local index_url="${2:?Usage: verify-nuget-publication.sh ARTIFACT_DIR INDEX_URL VERSION}" - local version="${3:?Usage: verify-nuget-publication.sh ARTIFACT_DIR INDEX_URL VERSION}" + local usage="Usage: verify-nuget-publication.sh ARTIFACT_DIR INDEX_URL SYMBOL_PACKAGE_BASE_URL VERSION" + local artifact_dir="${1:?$usage}" + local index_url="${2:?$usage}" + local symbol_package_base_url="${3:?$usage}" + local version="${4:?$usage}" local host package_id package_url local_package published_package + local symbol_package_url local_symbol_package published_symbol_package local deadline=${NUGET_WAIT_DEADLINE_SECONDS:-1800} local interval=${NUGET_WAIT_INTERVAL_SECONDS:-15} local scratch_dir @@ -104,6 +108,10 @@ main() { echo "NuGet index URL must use HTTPS and include a path: $index_url" >&2 exit 1 } + [[ "$symbol_package_base_url" =~ ^https://[^/]+/[^/]+$ ]] || { + echo "Symbol package base URL must use HTTPS: $symbol_package_base_url" >&2 + exit 1 + } [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] || { echo "Invalid NuGet version: $version" >&2 exit 1 @@ -128,12 +136,25 @@ main() { pending+=("$package_id") continue fi - local_package=$(find_local_package "$artifact_dir" "$package_id" "$version") + local_package=$(find_local_package "$artifact_dir" "$package_id" "$version" nupkg) if ! verify_published_package "$local_package" "$published_package" "$scratch_dir"; then echo "Published $package_id $version does not match the workflow artifact" >&2 exit 1 fi - echo " $package_id $version is available and matches the workflow artifact" + + symbol_package_url="$symbol_package_base_url/$package_id.$version.snupkg" + published_symbol_package="$scratch_dir/$package_id.snupkg" + if ! curl -fsSL "$symbol_package_url" -o "$published_symbol_package" 2>/dev/null; then + pending+=("$package_id") + continue + fi + local_symbol_package=$(find_local_package "$artifact_dir" "$package_id" "$version" snupkg) + if ! verify_published_package \ + "$local_symbol_package" "$published_symbol_package" "$scratch_dir"; then + echo "Published symbols for $package_id $version do not match the workflow artifact" >&2 + exit 1 + fi + echo " $package_id $version runtime and symbol packages match the workflow artifact" done remaining=("${pending[@]}") (( ${#remaining[@]} == 0 )) && break @@ -147,7 +168,7 @@ main() { echo "Waiting for ${interval}s..." sleep "$interval" done - echo "All packages $version are available on $host and match the workflow artifact" + echo "All runtime and symbol packages $version match the workflow artifact" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 998a5649..0d7aa1c9 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -19,6 +19,10 @@ on: /v3-flatcontainer) to confirm the packages are restorable. required: true type: string + symbol-package-base-url: + description: "Base URL from which the gallery's published .snupkg files can be downloaded." + required: true + type: string token-service-url: description: "Trusted Publishing OIDC token-exchange endpoint for this gallery." required: true @@ -130,4 +134,10 @@ jobs: env: VERSION: ${{ inputs.version }} INDEX_URL: ${{ inputs.index-url }} - run: .github/scripts/verify-nuget-publication.sh "$GITHUB_WORKSPACE/nuget-package" "$INDEX_URL" "$VERSION" + SYMBOL_PACKAGE_BASE_URL: ${{ inputs.symbol-package-base-url }} + run: >- + .github/scripts/verify-nuget-publication.sh + "$GITHUB_WORKSPACE/nuget-package" + "$INDEX_URL" + "$SYMBOL_PACKAGE_BASE_URL" + "$VERSION" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 3844ba2a..73f29275 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -95,6 +95,7 @@ jobs: with: environment: nugetint index-url: https://apiint.nugettest.org/v3/index.json + symbol-package-base-url: https://globalcdn.int.nugettest.org/symbol-packages token-service-url: https://int.nugettest.org/api/v2/token audience: https://int.nugettest.org artifact-run-id: ${{ needs.candidate.outputs.artifact-run-id }} @@ -135,6 +136,7 @@ jobs: with: environment: nugetprod index-url: https://api.nuget.org/v3/index.json + symbol-package-base-url: https://globalcdn.nuget.org/symbol-packages token-service-url: https://www.nuget.org/api/v2/token artifact-run-id: ${{ needs.candidate.outputs.artifact-run-id }} commit: ${{ needs.candidate.outputs.commit }} From fae3b8d2fdebbcacc039e40d5afa747a8ab8998a Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 19 Aug 2026 12:51:19 -0500 Subject: [PATCH 6/8] Use automatic latest release selection --- .github/scripts/create-github-release.sh | 84 +++------------------- .github/scripts/test-release-automation.sh | 29 +++----- 2 files changed, 22 insertions(+), 91 deletions(-) diff --git a/.github/scripts/create-github-release.sh b/.github/scripts/create-github-release.sh index 293ff1c8..599cc333 100755 --- a/.github/scripts/create-github-release.sh +++ b/.github/scripts/create-github-release.sh @@ -8,76 +8,16 @@ set -euo pipefail -version_is_newer() { - local candidate_version=$1 - local current_version=${2#v} - local -a candidate_parts current_parts - local index candidate_part current_part +set_release_args() { + local prerelease=$1 - [[ "$candidate_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 - [[ "$current_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 - IFS=. read -r -a candidate_parts <<< "$candidate_version" - IFS=. read -r -a current_parts <<< "$current_version" - for index in 0 1 2; do - candidate_part=$((10#${candidate_parts[$index]})) - current_part=$((10#${current_parts[$index]})) - (( candidate_part > current_part )) && return 0 - (( candidate_part < current_part )) && return 1 - done - return 1 -} - -select_latest_release() { - local candidate_version=$1 - shift - local entry release_id release_tag release_version - - latest_release_id= - latest_release_tag= - latest_release_version= - candidate_found=false - for entry in "$@"; do - IFS=$'\t' read -r release_id release_tag <<< "$entry" - [[ "$release_tag" == "$candidate_version" ]] && candidate_found=true - release_version=${release_tag#v} - [[ "$release_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue - if [[ -z "$latest_release_id" ]] || \ - version_is_newer "$release_version" "$latest_release_version"; then - latest_release_id=$release_id - latest_release_tag=$release_tag - latest_release_version=$release_version - fi - done - if [[ "$candidate_found" != true || -z "$latest_release_id" ]]; then - echo "Unable to find the new release while selecting the latest stable version" >&2 - return 1 - fi -} - -reconcile_latest_release() { - local candidate_version=$1 - local prerelease=$2 - local stable_release_data - local -a stable_releases - - [[ "$prerelease" == true ]] && return - [[ "$prerelease" == false ]] || { + release_args=() + if [[ "$prerelease" == true ]]; then + release_args=(--prerelease --latest=false) + elif [[ "$prerelease" != false ]]; then echo "PRERELEASE must be true or false, got: $prerelease" >&2 return 1 - } - - stable_release_data=$(gh api --paginate \ - "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ - --jq '.[] | select(.draft == false and .prerelease == false) | [.id, .tag_name] | @tsv') - mapfile -t stable_releases <<< "$stable_release_data" - select_latest_release "$candidate_version" "${stable_releases[@]}" - - # Every concurrent release independently selects the same highest stable - # version after it becomes visible. The release created last also reconciles - # last, so completion order cannot leave an older version marked as latest. - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$latest_release_id" \ - -f make_latest=true --silent - echo "GitHub release $latest_release_tag is the latest stable release" + fi } main() { @@ -125,7 +65,6 @@ main() { exit 1 fi echo "GitHub release $version already exists at the expected tag" - reconcile_latest_release "$version" "$prerelease" exit 0 fi @@ -154,16 +93,15 @@ main() { cat "$generated_notes" } > "$release_notes" - # Defer the latest marker until after creation so concurrent versions can - # reconcile it deterministically instead of racing read-then-create calls. - release_args=(--latest=false) - [[ "$prerelease" == true ]] && release_args=(--prerelease --latest=false) + # Stable releases use GitHub's server-side default, which selects latest + # automatically by date and semantic version without a client-side race. + # Prereleases must be explicitly excluded from latest selection. + set_release_args "$prerelease" gh release create "$version" \ "${tag_args[@]}" \ "${release_args[@]}" \ --title "$version" \ --notes-file "$release_notes" - reconcile_latest_release "$version" "$prerelease" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/.github/scripts/test-release-automation.sh b/.github/scripts/test-release-automation.sh index 5325cb7e..ad4f04ce 100755 --- a/.github/scripts/test-release-automation.sh +++ b/.github/scripts/test-release-automation.sh @@ -19,29 +19,22 @@ readonly test_dir trap 'rm -rf "$test_dir"' EXIT export GITHUB_REPOSITORY=temporalio/sdk-dotnet export RUNNER_TEMP="$test_dir" -declare latest_release_id latest_release_tag +declare -a release_args -select_latest_release 1.2.3 \ - $'101\t1.2.2' \ - $'102\t1.2.3' \ - $'103\t1.3.0-beta.1' \ - $'104\tv1.1.9' -[[ "$latest_release_id" == 102 && "$latest_release_tag" == 1.2.3 ]] || { - echo "The highest stable release was not selected" >&2 +set_release_args false +if (( ${#release_args[@]} != 0 )); then + echo "Stable releases must leave latest selection to GitHub" >&2 exit 1 -} +fi -# A later stable release must win even when the older candidate finishes last. -select_latest_release 1.2.3 \ - $'201\t1.2.3' \ - $'202\t1.2.4' -[[ "$latest_release_id" == 202 && "$latest_release_tag" == 1.2.4 ]] || { - echo "An older retry would incorrectly replace a newer latest release" >&2 +set_release_args true +if [[ "${release_args[*]}" != '--prerelease --latest=false' ]]; then + echo "Prereleases must be excluded from latest selection" >&2 exit 1 -} +fi -if select_latest_release 1.2.3 $'301\t1.2.4' 2>/dev/null; then - echo "Latest selection accepted a listing that omitted the new release" >&2 +if set_release_args invalid 2>/dev/null; then + echo "Invalid prerelease metadata was accepted" >&2 exit 1 fi From 29f13564bf321f86e85d8ea8a1628c893a223987 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 19 Aug 2026 12:54:26 -0500 Subject: [PATCH 7/8] Request legacy latest release selection --- .github/scripts/create-github-release.sh | 37 ++++++++++++---------- .github/scripts/test-release-automation.sh | 14 ++++---- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.github/scripts/create-github-release.sh b/.github/scripts/create-github-release.sh index 599cc333..1a14384c 100755 --- a/.github/scripts/create-github-release.sh +++ b/.github/scripts/create-github-release.sh @@ -8,12 +8,15 @@ set -euo pipefail -set_release_args() { +set_release_metadata() { local prerelease=$1 - release_args=() if [[ "$prerelease" == true ]]; then - release_args=(--prerelease --latest=false) + release_prerelease=true + make_latest=false + elif [[ "$prerelease" == false ]]; then + release_prerelease=false + make_latest=legacy elif [[ "$prerelease" != false ]]; then echo "PRERELEASE must be true or false, got: $prerelease" >&2 return 1 @@ -25,8 +28,7 @@ main() { local release_commit="${2:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" local prerelease="${3:?Usage: create-github-release.sh VERSION COMMIT PRERELEASE}" local generated_notes notable_changes release release_draft release_name - local release_notes release_prerelease tag_commit - local -a release_args tag_args + local release_notes release_prerelease tag_commit make_latest : "${GH_TOKEN:?GH_TOKEN must authenticate GitHub CLI requests}" : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must identify the release repository}" @@ -41,15 +43,13 @@ main() { release_notes="$RUNNER_TEMP/release-notes.md" # Refuse to reuse a version tag from another commit. If the tag does not - # exist, `gh release create` creates it at the immutable release commit. - tag_args=(--target "$release_commit") + # exist, the create-release API creates it at the immutable release commit. if git rev-parse --verify --quiet "refs/tags/$version" >/dev/null; then tag_commit=$(git rev-list -n 1 "$version") if [[ "$tag_commit" != "$release_commit" ]]; then echo "Tag $version points to $tag_commit, expected $release_commit" >&2 exit 1 fi - tag_args=(--verify-tag) fi # A successful rerun must not replace or silently modify an existing release. @@ -93,15 +93,18 @@ main() { cat "$generated_notes" } > "$release_notes" - # Stable releases use GitHub's server-side default, which selects latest - # automatically by date and semantic version without a client-side race. - # Prereleases must be explicitly excluded from latest selection. - set_release_args "$prerelease" - gh release create "$version" \ - "${tag_args[@]}" \ - "${release_args[@]}" \ - --title "$version" \ - --notes-file "$release_notes" + # Request legacy selection explicitly: omitting make_latest defaults to true, + # while legacy makes GitHub choose by date and semantic version server-side. + set_release_metadata "$prerelease" + gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + -f tag_name="$version" \ + -f target_commitish="$release_commit" \ + -f name="$version" \ + -F body=@"$release_notes" \ + -F draft=false \ + -F prerelease="$release_prerelease" \ + -f make_latest="$make_latest" \ + --silent } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/.github/scripts/test-release-automation.sh b/.github/scripts/test-release-automation.sh index ad4f04ce..b1823814 100755 --- a/.github/scripts/test-release-automation.sh +++ b/.github/scripts/test-release-automation.sh @@ -19,21 +19,21 @@ readonly test_dir trap 'rm -rf "$test_dir"' EXIT export GITHUB_REPOSITORY=temporalio/sdk-dotnet export RUNNER_TEMP="$test_dir" -declare -a release_args +declare make_latest release_prerelease -set_release_args false -if (( ${#release_args[@]} != 0 )); then - echo "Stable releases must leave latest selection to GitHub" >&2 +set_release_metadata false +if [[ "$release_prerelease" != false || "$make_latest" != legacy ]]; then + echo "Stable releases must explicitly use GitHub's legacy latest selection" >&2 exit 1 fi -set_release_args true -if [[ "${release_args[*]}" != '--prerelease --latest=false' ]]; then +set_release_metadata true +if [[ "$release_prerelease" != true || "$make_latest" != false ]]; then echo "Prereleases must be excluded from latest selection" >&2 exit 1 fi -if set_release_args invalid 2>/dev/null; then +if set_release_metadata invalid 2>/dev/null; then echo "Invalid prerelease metadata was accepted" >&2 exit 1 fi From 527ed7d986001fbbbda3f1f0e233715f261a689b Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 19 Aug 2026 13:08:19 -0500 Subject: [PATCH 8/8] Remove release automation tests --- .github/scripts/test-release-automation.sh | 69 ---------------------- .github/workflows/ci.yml | 11 ---- 2 files changed, 80 deletions(-) delete mode 100755 .github/scripts/test-release-automation.sh diff --git a/.github/scripts/test-release-automation.sh b/.github/scripts/test-release-automation.sh deleted file mode 100755 index b1823814..00000000 --- a/.github/scripts/test-release-automation.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash - -# Exercise the retry decisions that cannot safely be tested against real NuGet -# or GitHub releases. All external state is represented by temporary fixtures -# and a mocked GitHub CLI response. - -set -euo pipefail - -script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -readonly script_dir -# The paths are resolved from this script so the test works from any directory. -# shellcheck disable=SC1091 -source "$script_dir/create-github-release.sh" -# shellcheck disable=SC1091 -source "$script_dir/verify-nuget-publication.sh" - -test_dir=$(mktemp -d) -readonly test_dir -trap 'rm -rf "$test_dir"' EXIT -export GITHUB_REPOSITORY=temporalio/sdk-dotnet -export RUNNER_TEMP="$test_dir" -declare make_latest release_prerelease - -set_release_metadata false -if [[ "$release_prerelease" != false || "$make_latest" != legacy ]]; then - echo "Stable releases must explicitly use GitHub's legacy latest selection" >&2 - exit 1 -fi - -set_release_metadata true -if [[ "$release_prerelease" != true || "$make_latest" != false ]]; then - echo "Prereleases must be excluded from latest selection" >&2 - exit 1 -fi - -if set_release_metadata invalid 2>/dev/null; then - echo "Invalid prerelease metadata was accepted" >&2 - exit 1 -fi - -mkdir -p "$test_dir/local" "$test_dir/package-content" "$test_dir/signed-content" -printf 'candidate package content\n' > "$test_dir/package-content/content.txt" -(cd "$test_dir/package-content" && zip -q "$test_dir/local/package.nupkg" content.txt) -cp "$test_dir/local/package.nupkg" "$test_dir/unsigned-published.nupkg" -verify_published_package \ - "$test_dir/local/package.nupkg" "$test_dir/unsigned-published.nupkg" "$test_dir" - -content_hash=$(openssl dgst -sha256 -binary "$test_dir/local/package.nupkg" | openssl base64 -A) -printf 'Version:1\n\n2.16.840.1.101.3.4.2.1-Hash:%s\n' "$content_hash" \ - > "$test_dir/signature-content.txt" -openssl req -x509 -newkey rsa:2048 -nodes -subj /CN=release-test \ - -keyout "$test_dir/key.pem" -out "$test_dir/cert.pem" -days 1 2>/dev/null -openssl cms -sign -binary -nodetach -in "$test_dir/signature-content.txt" \ - -signer "$test_dir/cert.pem" -inkey "$test_dir/key.pem" -outform DER \ - -out "$test_dir/signed-content/.signature.p7s" -cp "$test_dir/local/package.nupkg" "$test_dir/signed-published.nupkg" -(cd "$test_dir/signed-content" && zip -q "$test_dir/signed-published.nupkg" .signature.p7s) -verify_published_package \ - "$test_dir/local/package.nupkg" "$test_dir/signed-published.nupkg" "$test_dir" - -printf 'different package content\n' > "$test_dir/package-content/content.txt" -(cd "$test_dir/package-content" && zip -q "$test_dir/local/conflict.nupkg" content.txt) -if verify_published_package \ - "$test_dir/local/conflict.nupkg" "$test_dir/signed-published.nupkg" "$test_dir"; then - echo "A conflicting published package was incorrectly accepted" >&2 - exit 1 -fi - -echo "Release automation tests passed" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19d7b188..4da81325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,17 +10,6 @@ permissions: contents: read jobs: - release-automation-tests: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Test release automation - run: .github/scripts/test-release-automation.sh - build-lint-test: env: RestoreLockedMode: true