diff --git a/.github/scripts/create-github-release.sh b/.github/scripts/create-github-release.sh new file mode 100755 index 00000000..1a14384c --- /dev/null +++ b/.github/scripts/create-github-release.sh @@ -0,0 +1,112 @@ +#!/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 + +set_release_metadata() { + local prerelease=$1 + + if [[ "$prerelease" == true ]]; then + 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 + 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 make_latest + + : "${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" + release_notes="$RUNNER_TEMP/release-notes.md" + + # Refuse to reuse a version tag from another commit. If the tag does not + # 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 + 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" + + # 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 + main "$@" +fi diff --git a/.github/scripts/verify-nuget-publication.sh b/.github/scripts/verify-nuget-publication.sh new file mode 100755 index 00000000..137a8983 --- /dev/null +++ b/.github/scripts/verify-nuget-publication.sh @@ -0,0 +1,176 @@ +#!/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 extension=$4 + local -a matches + + mapfile -t matches < <( + find "$artifact_dir" -type f -iname "$package_id.$version.$extension" + ) + if [[ ${#matches[@]} -ne 1 ]]; then + echo "Expected exactly one $extension artifact for $package_id $version, found ${#matches[@]}" >&2 + return 1 + fi + printf '%s\n' "${matches[0]}" +} + +main() { + 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 + local -a remaining pending + + [[ "$index_url" =~ ^https://[^/]+/ ]] || { + 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 + } + + # 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" 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 + + 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 + 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 runtime and symbol packages $version match the workflow artifact" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 8b1a9be2..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 @@ -40,8 +44,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 +62,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 @@ -93,39 +111,33 @@ 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.diagnosticsource 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="$pending $pkg" - fi - done - remaining="${pending# }" - [ -z "$remaining" ] && 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" + 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 bf731264..73f29275 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -1,60 +1,111 @@ 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: + # 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 - 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 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 - 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,33 +113,39 @@ 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 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 - 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 +153,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"