fix(ci): decouple releases from the devnet faucet (#12) #15
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: release | |
| # Publishes the single SolSharp package to NuGet when a version tag (e.g. v0.1.0-alpha) is pushed. | |
| # Uses NuGet Trusted Publishing (OIDC) via the jecacs/SolSharp policy — no long-lived API key is stored. | |
| on: | |
| push: | |
| tags: [ 'v*' ] | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| validate: | |
| name: validate-release | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| package_name: ${{ steps.package.outputs.name }} | |
| package_sha256: ${{ steps.package.outputs.sha256 }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Set up .NET 8 | |
| uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 | |
| with: | |
| dotnet-version: 8.0.x | |
| dotnet-quality: ga | |
| - name: Verify .NET 8 SDK selection | |
| shell: bash | |
| run: | | |
| selected_version="$(dotnet --version)" | |
| if [[ "${selected_version}" != 8.* ]]; then | |
| echo "Expected a .NET 8 SDK, but dotnet selected ${selected_version}." | |
| dotnet --info | |
| exit 1 | |
| fi | |
| - name: Restore and audit dependencies | |
| run: dotnet restore --locked-mode -p:NuGetAuditMode=all -warnaserror | |
| - name: Validate tag matches package version | |
| shell: bash | |
| run: | | |
| package_version="$(dotnet msbuild src/SolSharp/SolSharp.csproj -getProperty:Version -nologo)" | |
| expected_tag="v${package_version}" | |
| if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then | |
| echo "Release tag ${GITHUB_REF_NAME} does not match package version ${package_version}." | |
| exit 1 | |
| fi | |
| - name: Require the tagged commit to be merged into main | |
| shell: bash | |
| run: | | |
| git fetch --no-tags origin main | |
| if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then | |
| echo "Release commit ${GITHUB_SHA} is not contained in origin/main." | |
| exit 1 | |
| fi | |
| - name: Build | |
| run: dotnet build --configuration Release --no-restore -warnaserror | |
| - name: Verify formatting and analyzer style | |
| run: dotnet format --no-restore --verify-no-changes --severity info | |
| - name: Require private integration endpoints | |
| shell: bash | |
| env: | |
| SOLSHARP_RPC_URL: ${{ secrets.SOLSHARP_RPC_URL }} | |
| SOLSHARP_WS_URL: ${{ secrets.SOLSHARP_WS_URL }} | |
| SOLSHARP_DEVNET_RPC_URL: ${{ secrets.SOLSHARP_DEVNET_RPC_URL }} | |
| run: | | |
| for variable in SOLSHARP_RPC_URL SOLSHARP_WS_URL SOLSHARP_DEVNET_RPC_URL; do | |
| value="${!variable:-}" | |
| if [[ -z "${value}" ]]; then | |
| echo "Required release integration secret ${variable} is not configured." | |
| exit 1 | |
| fi | |
| parsed_endpoint="$(python3 -c ' | |
| import sys | |
| from urllib.parse import urlsplit | |
| try: | |
| endpoint = urlsplit(sys.argv[1]) | |
| _ = endpoint.port | |
| except ValueError as error: | |
| raise SystemExit(f"Invalid endpoint URI: {error}") from error | |
| if not endpoint.scheme or not endpoint.hostname: | |
| raise SystemExit("Endpoint URI must include a scheme and hostname.") | |
| print(endpoint.scheme.lower(), endpoint.hostname.rstrip(".").lower()) | |
| ' "${value}")" || exit 1 | |
| read -r endpoint_scheme endpoint_host <<< "${parsed_endpoint}" | |
| case "${variable}:${endpoint_scheme}" in | |
| SOLSHARP_RPC_URL:http|SOLSHARP_RPC_URL:https|SOLSHARP_DEVNET_RPC_URL:http|SOLSHARP_DEVNET_RPC_URL:https|SOLSHARP_WS_URL:ws|SOLSHARP_WS_URL:wss) | |
| ;; | |
| *) | |
| echo "Release integration secret ${variable} has an unsupported URI scheme." | |
| exit 1 | |
| ;; | |
| esac | |
| case "${endpoint_host}" in | |
| api.mainnet-beta.solana.com|api.devnet.solana.com|api.testnet.solana.com) | |
| echo "Release integration secret ${variable} must use a private endpoint, not a public fallback." | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| # Unit tests plus live read/streaming integration form the blocking release gate. Unlike ordinary local | |
| # runs, this pass is strict: transport failures and every inconclusive/skipped result fail the job. | |
| - name: Test (strict suite and live reads) | |
| run: >- | |
| dotnet test | |
| --no-build | |
| --configuration Release | |
| --filter "TestCategory!=DevnetWrite" | |
| --logger "console;verbosity=normal" | |
| --logger trx | |
| --results-directory "${{ runner.temp }}/solsharp-strict-test-results" | |
| env: | |
| SOLSHARP_RPC_URL: ${{ secrets.SOLSHARP_RPC_URL }} | |
| SOLSHARP_WS_URL: ${{ secrets.SOLSHARP_WS_URL }} | |
| SOLSHARP_INTEGRATION_STRICT: 'true' | |
| - name: Reject inconclusive or skipped strict tests | |
| shell: bash | |
| env: | |
| STRICT_RESULTS_DIR: ${{ runner.temp }}/solsharp-strict-test-results | |
| run: | | |
| mapfile -t result_files < <(find "${STRICT_RESULTS_DIR}" -type f -name '*.trx') | |
| if (( ${#result_files[@]} == 0 )); then | |
| echo "No strict-suite TRX test results were produced." | |
| exit 1 | |
| fi | |
| if grep --with-filename --extended-regexp \ | |
| 'outcome="(NotExecuted|Skipped|Inconclusive|NotRunnable|Warning)"' "${result_files[@]}"; then | |
| echo "Strict release tests contained an inconclusive or skipped result." | |
| exit 1 | |
| fi | |
| # requestAirdrop is deliberately not retried because it is not idempotent, and the shared devnet faucet | |
| # can return 429/-32603 even through a healthy private RPC provider. Attempt the real write paths on every | |
| # release, but let only classified faucet/transport failures become inconclusive; deterministic client, | |
| # wire, assertion, or cluster-safety failures still fail this step. | |
| - name: Probe devnet writes (faucet-limited) | |
| run: >- | |
| dotnet test tests/SolSharp.IntegrationTests/SolSharp.IntegrationTests.csproj | |
| --no-build | |
| --configuration Release | |
| --filter "TestCategory=DevnetWrite" | |
| --logger "console;verbosity=normal" | |
| --logger trx | |
| --results-directory "${{ runner.temp }}/solsharp-devnet-write-results" | |
| env: | |
| SOLSHARP_DEVNET_RPC_URL: ${{ secrets.SOLSHARP_DEVNET_RPC_URL }} | |
| SOLSHARP_INTEGRATION_STRICT: 'false' | |
| # No --no-build: packing rebuilds so the facade's bundling target (which folds the four | |
| # assemblies into the single SolSharp package) runs against fully resolved references. | |
| - name: Pack | |
| run: dotnet pack src/SolSharp/SolSharp.csproj --configuration Release --output ./artifacts --no-restore -warnaserror | |
| # Exercise the exact artifact about to be published, including bundled assemblies and the native | |
| # BLS backend. This catches package-only dependency/RID regressions that a project-reference build | |
| # cannot see. | |
| - name: Publish packed package consumer (Native AOT) | |
| shell: bash | |
| env: | |
| NUGET_PACKAGES: ${{ runner.temp }}/solsharp-release-packages | |
| run: | | |
| package_version="${GITHUB_REF_NAME#v}" | |
| dotnet restore samples/SolSharp.AotSmoke \ | |
| --runtime linux-x64 \ | |
| -p:UsePackedSolSharp=true \ | |
| -p:SolSharpPackageVersion="${package_version}" \ | |
| --source ./artifacts \ | |
| --source https://api.nuget.org/v3/index.json \ | |
| -p:NuGetAuditMode=all \ | |
| -warnaserror | |
| artifact_path="$(find ./artifacts -maxdepth 1 -iname "solsharp.${package_version}.nupkg" -print -quit)" | |
| restored_path="${NUGET_PACKAGES}/solsharp/${package_version}/solsharp.${package_version}.nupkg" | |
| if [[ -z "${artifact_path}" || ! -f "${restored_path}" ]] || ! cmp --silent "${artifact_path}" "${restored_path}"; then | |
| echo "The AOT consumer did not restore the exact package artifact produced by this release." | |
| sha256sum "${artifact_path}" "${restored_path}" 2>/dev/null || true | |
| exit 1 | |
| fi | |
| dotnet publish samples/SolSharp.AotSmoke \ | |
| --configuration Release \ | |
| --runtime linux-x64 \ | |
| --no-restore \ | |
| -warnaserror \ | |
| -p:UsePackedSolSharp=true \ | |
| -p:SolSharpPackageVersion="${package_version}" | |
| - name: Run packed package consumer | |
| run: samples/SolSharp.AotSmoke/bin/Release/net8.0/linux-x64/publish/SolSharp.AotSmoke | |
| - name: Record exact package artifact | |
| id: package | |
| shell: bash | |
| run: | | |
| package_version="${GITHUB_REF_NAME#v}" | |
| expected_name="SolSharp.${package_version}.nupkg" | |
| package_path="./artifacts/${expected_name}" | |
| if [[ ! -f "${package_path}" ]]; then | |
| echo "Expected release artifact ${package_path} was not produced." | |
| find ./artifacts -maxdepth 1 -type f -print | |
| exit 1 | |
| fi | |
| mapfile -t packages < <(find ./artifacts -maxdepth 1 -type f -name '*.nupkg' -print) | |
| if (( ${#packages[@]} != 1 )); then | |
| echo "Expected exactly one package artifact, found ${#packages[@]}." | |
| printf '%s\n' "${packages[@]}" | |
| exit 1 | |
| fi | |
| package_sha256="$(sha256sum "${package_path}" | cut -d ' ' -f 1)" | |
| printf '%s %s\n' "${package_sha256}" "${expected_name}" > "${package_path}.sha256" | |
| echo "name=${expected_name}" >> "${GITHUB_OUTPUT}" | |
| echo "sha256=${package_sha256}" >> "${GITHUB_OUTPUT}" | |
| - name: Transfer verified release artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: release-package | |
| path: | | |
| ./artifacts/*.nupkg | |
| ./artifacts/*.nupkg.sha256 | |
| if-no-files-found: error | |
| overwrite: true | |
| retention-days: 7 | |
| attest_package: | |
| name: attest-package | |
| needs: validate | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| id-token: write # required only for package attestation | |
| attestations: write | |
| steps: | |
| # download-artifact validates the workflow artifact digest recorded by upload-artifact. | |
| - name: Download verified release artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: release-package | |
| path: ./artifacts | |
| - name: Verify exact package digest | |
| shell: bash | |
| env: | |
| PACKAGE_NAME: ${{ needs.validate.outputs.package_name }} | |
| PACKAGE_SHA256: ${{ needs.validate.outputs.package_sha256 }} | |
| run: | | |
| package_path="./artifacts/${PACKAGE_NAME}" | |
| printf '%s %s\n' "${PACKAGE_SHA256}" "${package_path}" | sha256sum --check --strict | |
| if ! printf '%s %s\n' "${PACKAGE_SHA256}" "${PACKAGE_NAME}" | \ | |
| cmp --silent - "${package_path}.sha256"; then | |
| echo "Package checksum artifact is not the exact canonical checksum record." | |
| exit 1 | |
| fi | |
| - name: Attest package provenance | |
| id: attest | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-path: ./artifacts/${{ needs.validate.outputs.package_name }} | |
| - name: Prepare verifiable release provenance | |
| shell: bash | |
| env: | |
| ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} | |
| GH_TOKEN: ${{ github.token }} | |
| PACKAGE_NAME: ${{ needs.validate.outputs.package_name }} | |
| PACKAGE_SHA256: ${{ needs.validate.outputs.package_sha256 }} | |
| run: | | |
| provenance_path="./artifacts/${PACKAGE_NAME}.sigstore.json" | |
| cp -- "${ATTESTATION_BUNDLE}" "${provenance_path}" | |
| jq --exit-status \ | |
| --arg package_name "${PACKAGE_NAME}" \ | |
| --arg package_sha256 "${PACKAGE_SHA256}" ' | |
| .mediaType == "application/vnd.dev.sigstore.bundle.v0.3+json" and | |
| .dsseEnvelope.payloadType == "application/vnd.in-toto+json" and | |
| ((.dsseEnvelope.payload | @base64d | fromjson) as $statement | | |
| $statement._type == "https://in-toto.io/Statement/v1" and | |
| $statement.predicateType == "https://slsa.dev/provenance/v1" and | |
| ($statement.subject | type) == "array" and | |
| ($statement.subject | length) == 1 and | |
| $statement.subject[0].name == $package_name and | |
| $statement.subject[0].digest.sha256 == $package_sha256) | |
| ' "${provenance_path}" > /dev/null | |
| gh attestation verify "./artifacts/${PACKAGE_NAME}" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --bundle "${provenance_path}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \ | |
| --source-ref "${GITHUB_REF}" \ | |
| --source-digest "${GITHUB_SHA}" \ | |
| --deny-self-hosted-runners | |
| - name: Transfer attested release candidate | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: attested-release-candidate | |
| path: | | |
| ./artifacts/*.nupkg | |
| ./artifacts/*.nupkg.sha256 | |
| ./artifacts/*.nupkg.sigstore.json | |
| if-no-files-found: error | |
| overwrite: true | |
| retention-days: 7 | |
| stage_release: | |
| name: stage-github-release | |
| needs: [ validate, attest_package ] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # required only for the durable draft GitHub Release | |
| outputs: | |
| release_id: ${{ steps.stage.outputs.release_id }} | |
| package_sha256: ${{ steps.stage.outputs.package_sha256 }} | |
| steps: | |
| - name: Download attested release candidate | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: attested-release-candidate | |
| path: ./candidate | |
| # SDK 8 pack embeds a random core-properties name. A retry therefore must recover the exact | |
| # bytes durably staged by the first run instead of treating its newly packed candidate as canonical. | |
| - name: Stage or recover canonical release assets | |
| id: stage | |
| shell: bash | |
| env: | |
| CANDIDATE_SHA256: ${{ needs.validate.outputs.package_sha256 }} | |
| GH_TOKEN: ${{ github.token }} | |
| PACKAGE_NAME: ${{ needs.validate.outputs.package_name }} | |
| run: | | |
| expected_title="SolSharp ${GITHUB_REF_NAME#v}" | |
| expected_prerelease=false | |
| if [[ "${GITHUB_REF_NAME}" == *-* ]]; then | |
| expected_prerelease=true | |
| fi | |
| api_version="2022-11-28" | |
| repo_api="/repos/${GITHUB_REPOSITORY}" | |
| encoded_tag="$(jq --null-input --raw-output \ | |
| --arg value "${GITHUB_REF_NAME}" '$value | @uri')" | |
| canonical_directory="./canonical" | |
| mkdir -p "${canonical_directory}" | |
| verify_tag_target() { | |
| local ref_state="${RUNNER_TEMP}/release-tag-ref.json" | |
| gh api \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/git/ref/tags/${encoded_tag}" \ | |
| > "${ref_state}" | |
| local object_type object_sha | |
| object_type="$(jq --exit-status --raw-output '.object.type' "${ref_state}")" | |
| object_sha="$(jq --exit-status --raw-output '.object.sha' "${ref_state}")" | |
| for _ in {1..9}; do | |
| case "${object_type}" in | |
| commit) | |
| if [[ "${object_sha}" != "${GITHUB_SHA}" ]]; then | |
| echo "Release tag ${GITHUB_REF_NAME} resolves to ${object_sha}, not ${GITHUB_SHA}." | |
| return 1 | |
| fi | |
| return 0 | |
| ;; | |
| tag) | |
| gh api \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/git/tags/${object_sha}" \ | |
| > "${ref_state}" | |
| object_type="$(jq --exit-status --raw-output '.object.type' "${ref_state}")" | |
| object_sha="$(jq --exit-status --raw-output '.object.sha' "${ref_state}")" | |
| ;; | |
| *) | |
| echo "Release tag resolves to unsupported Git object type ${object_type}." | |
| return 1 | |
| ;; | |
| esac | |
| done | |
| echo "Release tag contains more than eight nested annotated tags." | |
| return 1 | |
| } | |
| verify_asset_directory() { | |
| local asset_directory="$1" | |
| local expected_sha256="${2:-}" | |
| local package_path="${asset_directory}/${PACKAGE_NAME}" | |
| local checksum_path="${package_path}.sha256" | |
| local provenance_path="${package_path}.sigstore.json" | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| if [[ ! -f "${asset_directory}/${asset_name}" || \ | |
| -L "${asset_directory}/${asset_name}" ]]; then | |
| echo "Required regular release asset ${asset_name} is missing." | |
| return 1 | |
| fi | |
| done | |
| local package_sha256 | |
| package_sha256="$(sha256sum "${package_path}" | cut -d ' ' -f 1)" | |
| if [[ -n "${expected_sha256}" && "${package_sha256}" != "${expected_sha256}" ]]; then | |
| echo "Release package digest does not match the expected candidate digest." | |
| return 1 | |
| fi | |
| if ! printf '%s %s\n' "${package_sha256}" "${PACKAGE_NAME}" | \ | |
| cmp --silent - "${checksum_path}"; then | |
| echo "Release checksum asset is not the exact canonical checksum record." | |
| return 1 | |
| fi | |
| if ! jq --exit-status \ | |
| --arg package_name "${PACKAGE_NAME}" \ | |
| --arg package_sha256 "${package_sha256}" ' | |
| .mediaType == "application/vnd.dev.sigstore.bundle.v0.3+json" and | |
| .dsseEnvelope.payloadType == "application/vnd.in-toto+json" and | |
| ((.dsseEnvelope.payload | @base64d | fromjson) as $statement | | |
| $statement._type == "https://in-toto.io/Statement/v1" and | |
| $statement.predicateType == "https://slsa.dev/provenance/v1" and | |
| ($statement.subject | type) == "array" and | |
| ($statement.subject | length) == 1 and | |
| $statement.subject[0].name == $package_name and | |
| $statement.subject[0].digest.sha256 == $package_sha256) | |
| ' "${provenance_path}" > /dev/null; then | |
| echo "Release provenance does not attest exactly the canonical package." | |
| return 1 | |
| fi | |
| gh attestation verify "${package_path}" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --bundle "${provenance_path}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \ | |
| --source-ref "${GITHUB_REF}" \ | |
| --source-digest "${GITHUB_SHA}" \ | |
| --deny-self-hosted-runners | |
| } | |
| get_published_release() { | |
| local output_file="$1" | |
| local status | |
| if ! status="$(curl --silent --show-error \ | |
| --header 'Accept: application/vnd.github+json' \ | |
| --header "Authorization: Bearer ${GH_TOKEN}" \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| --output "${output_file}" \ | |
| --write-out '%{http_code}' \ | |
| "https://api.github.com${repo_api}/releases/tags/${encoded_tag}")"; then | |
| echo "GitHub's published-release lookup failed." | |
| return 2 | |
| fi | |
| case "${status}" in | |
| 200) return 0 ;; | |
| 404) return 1 ;; | |
| *) | |
| echo "GitHub's published-release lookup returned HTTP ${status}." | |
| cat "${output_file}" | |
| return 2 | |
| ;; | |
| esac | |
| } | |
| read_release_by_id() { | |
| local release_id="$1" | |
| local output_file="$2" | |
| local metadata_file asset_pages | |
| metadata_file="$(mktemp "${RUNNER_TEMP}/solsharp-release-metadata.XXXXXX")" | |
| asset_pages="$(mktemp "${RUNNER_TEMP}/solsharp-release-asset-pages.XXXXXX")" | |
| gh api \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/releases/${release_id}" \ | |
| > "${metadata_file}" | |
| gh api --paginate --slurp \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/releases/${release_id}/assets?per_page=100" \ | |
| > "${asset_pages}" | |
| jq --slurpfile asset_pages "${asset_pages}" \ | |
| '.assets = (($asset_pages[0] | add) // [])' \ | |
| "${metadata_file}" > "${output_file}" | |
| } | |
| verify_release_identity() { | |
| local state_file="$1" | |
| local expected_draft="$2" | |
| if ! jq --exit-status \ | |
| --arg release_name "${expected_title}" \ | |
| --arg tag_name "${GITHUB_REF_NAME}" \ | |
| --argjson is_draft "${expected_draft}" \ | |
| --argjson is_prerelease "${expected_prerelease}" ' | |
| (.id | type) == "number" and | |
| .tag_name == $tag_name and | |
| .name == $release_name and | |
| .author.login == "github-actions[bot]" and | |
| .draft == $is_draft and | |
| .prerelease == $is_prerelease | |
| ' "${state_file}" > /dev/null; then | |
| echo "GitHub Release identity does not match this tag workflow." | |
| jq '{ id, tag_name, target_commitish, name, author: .author.login, draft, prerelease }' \ | |
| "${state_file}" | |
| return 1 | |
| fi | |
| } | |
| verify_release_asset_set() { | |
| local state_file="$1" | |
| if ! jq --exit-status \ | |
| --arg package "${PACKAGE_NAME}" \ | |
| --arg checksum "${PACKAGE_NAME}.sha256" \ | |
| --arg provenance "${PACKAGE_NAME}.sigstore.json" ' | |
| (.assets | type) == "array" and | |
| (.assets | length) == 3 and | |
| ([.assets[].name] | sort) == ([$package, $checksum, $provenance] | sort) and | |
| all(.assets[]; | |
| (.id | type) == "number" and | |
| (.size | type) == "number" and .size > 0 and | |
| .state == "uploaded" and | |
| (.digest | type) == "string" and | |
| (.digest | test("^sha256:[0-9a-f]{64}$")) and | |
| .uploader.login == "github-actions[bot]") | |
| ' "${state_file}" > /dev/null; then | |
| echo "GitHub Release has an unexpected asset set." | |
| jq '[.assets[] | { id, name, state, size, digest, uploader: .uploader.login }]' \ | |
| "${state_file}" | |
| return 1 | |
| fi | |
| } | |
| verify_recoverable_partial_draft() { | |
| local state_file="$1" | |
| jq --exit-status \ | |
| --arg package "${PACKAGE_NAME}" \ | |
| --arg checksum "${PACKAGE_NAME}.sha256" \ | |
| --arg provenance "${PACKAGE_NAME}.sigstore.json" \ | |
| --arg release_name "${expected_title}" \ | |
| --arg tag_name "${GITHUB_REF_NAME}" \ | |
| --argjson is_prerelease "${expected_prerelease}" ' | |
| .tag_name == $tag_name and | |
| .name == $release_name and | |
| .author.login == "github-actions[bot]" and | |
| .draft == true and | |
| .prerelease == $is_prerelease and | |
| (.assets | type) == "array" and | |
| (.assets | length) <= 3 and | |
| all(.assets[]; | |
| (.name == $package or .name == $checksum or .name == $provenance) and | |
| (.id | type) == "number" and | |
| .uploader.login == "github-actions[bot]" and | |
| (.state == "starter" or | |
| (.state == "uploaded" and | |
| (.size | type) == "number" and .size > 0 and | |
| (.digest | type) == "string" and | |
| (.digest | test("^sha256:[0-9a-f]{64}$"))))) | |
| ' "${state_file}" > /dev/null | |
| } | |
| verify_release_state() { | |
| verify_release_identity "$1" "$2" && verify_release_asset_set "$1" | |
| } | |
| download_release_assets() { | |
| local state_file="$1" | |
| local asset_directory="$2" | |
| mkdir -p "${asset_directory}" | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| local asset_id | |
| asset_id="$(jq --exit-status --raw-output \ | |
| --arg asset_name "${asset_name}" ' | |
| [.assets[] | select(.name == $asset_name) | .id] | | |
| if length == 1 and (.[0] | type) == "number" | |
| then .[0] | |
| else error("unexpected release asset set") | |
| end | |
| ' "${state_file}")" | |
| curl --fail --silent --show-error --location \ | |
| --header 'Accept: application/octet-stream' \ | |
| --header "Authorization: Bearer ${GH_TOKEN}" \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| --output "${asset_directory}/${asset_name}" \ | |
| "https://api.github.com${repo_api}/releases/assets/${asset_id}" | |
| done | |
| local actual_digest api_digest | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| actual_digest="sha256:$(sha256sum "${asset_directory}/${asset_name}" | cut -d ' ' -f 1)" | |
| api_digest="$(jq --exit-status --raw-output \ | |
| --arg asset_name "${asset_name}" ' | |
| [.assets[] | select(.name == $asset_name) | .digest] | | |
| if length == 1 then .[0] else error("unexpected release asset set") end | |
| ' "${state_file}")" | |
| if [[ "${actual_digest}" != "${api_digest}" ]]; then | |
| echo "Downloaded release asset ${asset_name} differs from GitHub's recorded digest." | |
| return 1 | |
| fi | |
| done | |
| verify_asset_directory "${asset_directory}" | |
| } | |
| emit_canonical_release() { | |
| local state_file="$1" | |
| local asset_directory="$2" | |
| local release_id package_sha256 | |
| verify_tag_target | |
| release_id="$(jq --exit-status --raw-output \ | |
| 'if (.id | type) == "number" then .id else error("missing release id") end' \ | |
| "${state_file}")" | |
| package_sha256="$(sha256sum "${asset_directory}/${PACKAGE_NAME}" | cut -d ' ' -f 1)" | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| cp -- "${asset_directory}/${asset_name}" "${canonical_directory}/${asset_name}" | |
| done | |
| { | |
| echo "release_id=${release_id}" | |
| echo "package_sha256=${package_sha256}" | |
| } >> "${GITHUB_OUTPUT}" | |
| } | |
| accept_published_release() { | |
| local tag_state_file="$1" | |
| local asset_directory release_id state_file | |
| release_id="$(jq --exit-status --raw-output \ | |
| 'if (.id | type) == "number" then .id else error("missing release id") end' \ | |
| "${tag_state_file}")" | |
| state_file="$(mktemp "${RUNNER_TEMP}/solsharp-published-state.XXXXXX")" | |
| read_release_by_id "${release_id}" "${state_file}" | |
| verify_release_state "${state_file}" false | |
| asset_directory="$(mktemp -d "${RUNNER_TEMP}/solsharp-published-assets.XXXXXX")" | |
| download_release_assets "${state_file}" "${asset_directory}" | |
| emit_canonical_release "${state_file}" "${asset_directory}" | |
| } | |
| verify_tag_target | |
| verify_asset_directory ./candidate "${CANDIDATE_SHA256}" | |
| published_state="${RUNNER_TEMP}/published-release.json" | |
| if get_published_release "${published_state}"; then | |
| accept_published_release "${published_state}" | |
| exit 0 | |
| else | |
| lookup_status=$? | |
| if (( lookup_status != 1 )); then | |
| exit "${lookup_status}" | |
| fi | |
| fi | |
| releases_pages="${RUNNER_TEMP}/release-pages.json" | |
| gh api --paginate --slurp \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/releases?per_page=100" \ | |
| > "${releases_pages}" | |
| draft_ids_file="${RUNNER_TEMP}/draft-ids.json" | |
| jq --exit-status \ | |
| --arg tag_name "${GITHUB_REF_NAME}" ' | |
| [.[][] | | |
| select(.draft == true and .tag_name == $tag_name and (.id | type) == "number") | | |
| .id] | sort | |
| ' "${releases_pages}" > "${draft_ids_file}" | |
| mapfile -t draft_ids < <(jq --raw-output '.[]' "${draft_ids_file}") | |
| valid_draft_state="" | |
| valid_draft_assets="" | |
| valid_draft_count=0 | |
| conflicting_draft_count=0 | |
| for draft_id in "${draft_ids[@]}"; do | |
| draft_state="${RUNNER_TEMP}/draft-${draft_id}.json" | |
| draft_assets="$(mktemp -d "${RUNNER_TEMP}/solsharp-draft-${draft_id}.XXXXXX")" | |
| if ! read_release_by_id "${draft_id}" "${draft_state}"; then | |
| conflicting_draft_count=$((conflicting_draft_count + 1)) | |
| elif verify_release_state "${draft_state}" true; then | |
| # A complete staged draft is already the durable canonical source. Any transport, | |
| # digest, or provenance failure must fail closed instead of rotating its package bytes. | |
| if ! download_release_assets "${draft_state}" "${draft_assets}"; then | |
| echo "Complete canonical draft ${draft_id} could not be verified; refusing recovery mutation." | |
| exit 1 | |
| fi | |
| valid_draft_count=$((valid_draft_count + 1)) | |
| valid_draft_state="${draft_state}" | |
| valid_draft_assets="${draft_assets}" | |
| elif verify_recoverable_partial_draft "${draft_state}"; then | |
| # Leave workflow-owned partial drafts untouched. Creating and tracking a new exact-ID | |
| # draft is safe, while a read-then-delete sequence could race with publication. | |
| echo "Leaving recoverable partial draft ${draft_id} unchanged." | |
| else | |
| conflicting_draft_count=$((conflicting_draft_count + 1)) | |
| fi | |
| done | |
| # A public release is authoritative if another actor completed a listed draft while its | |
| # exact state was being inspected. Resolve that transition before reporting conflicts. | |
| if get_published_release "${published_state}"; then | |
| accept_published_release "${published_state}" | |
| exit 0 | |
| else | |
| lookup_status=$? | |
| if (( lookup_status != 1 )); then | |
| exit "${lookup_status}" | |
| fi | |
| fi | |
| if (( conflicting_draft_count > 0 )); then | |
| echo "A same-tag draft is not an expected workflow-owned partial release; refusing mutation." | |
| exit 1 | |
| fi | |
| if (( valid_draft_count > 1 )); then | |
| echo "Multiple valid canonical drafts exist for ${GITHUB_REF_NAME}; refusing an ambiguous release." | |
| exit 1 | |
| fi | |
| if (( valid_draft_count == 1 )); then | |
| emit_canonical_release "${valid_draft_state}" "${valid_draft_assets}" | |
| exit 0 | |
| fi | |
| package_version="${GITHUB_REF_NAME#v}" | |
| lower_version="${package_version,,}" | |
| nuget_url="https://api.nuget.org/v3-flatcontainer/solsharp/${lower_version}/solsharp.${lower_version}.nupkg" | |
| nuget_status="$(curl --silent --show-error --location \ | |
| --output /dev/null --write-out '%{http_code}' "${nuget_url}")" | |
| case "${nuget_status}" in | |
| 404) ;; | |
| 200) | |
| echo "NuGet already contains this version without a valid canonical GitHub Release." | |
| exit 1 | |
| ;; | |
| *) | |
| echo "NuGet existence lookup returned HTTP ${nuget_status}; refusing release mutation." | |
| exit 1 | |
| ;; | |
| esac | |
| # Partial same-tag drafts remain untouched. Re-check the authoritative public endpoint | |
| # before creating and tracking a fresh exact-ID draft alongside them. | |
| if get_published_release "${published_state}"; then | |
| accept_published_release "${published_state}" | |
| exit 0 | |
| else | |
| lookup_status=$? | |
| if (( lookup_status != 1 )); then | |
| exit "${lookup_status}" | |
| fi | |
| fi | |
| created_state="${RUNNER_TEMP}/created-release.json" | |
| verify_tag_target | |
| gh api --method POST \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| --raw-field tag_name="${GITHUB_REF_NAME}" \ | |
| --raw-field target_commitish="${GITHUB_SHA}" \ | |
| --raw-field name="${expected_title}" \ | |
| --field draft=true \ | |
| --field prerelease="${expected_prerelease}" \ | |
| --field generate_release_notes=true \ | |
| "${repo_api}/releases" \ | |
| > "${created_state}" | |
| release_id="$(jq --exit-status --raw-output \ | |
| 'if (.id | type) == "number" then .id else error("missing release id") end' \ | |
| "${created_state}")" | |
| verify_release_identity "${created_state}" true | |
| # Do not retry an ambiguous upload response in place: GitHub can leave a starter asset | |
| # after a 502. A workflow rerun leaves that partial draft untouched and stages a fresh | |
| # exact-ID draft if no canonical release exists. | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| encoded_asset_name="$(jq --null-input --raw-output \ | |
| --arg value "${asset_name}" '$value | @uri')" | |
| curl --fail --silent --show-error \ | |
| --request POST \ | |
| --header 'Accept: application/vnd.github+json' \ | |
| --header "Authorization: Bearer ${GH_TOKEN}" \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| --header 'Content-Type: application/octet-stream' \ | |
| --data-binary "@./candidate/${asset_name}" \ | |
| "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=${encoded_asset_name}" \ | |
| > /dev/null | |
| done | |
| read_release_by_id "${release_id}" "${created_state}" | |
| verify_release_state "${created_state}" true | |
| staged_assets="$(mktemp -d "${RUNNER_TEMP}/solsharp-staged-assets.XXXXXX")" | |
| download_release_assets "${created_state}" "${staged_assets}" | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| if ! cmp --silent "./candidate/${asset_name}" "${staged_assets}/${asset_name}"; then | |
| echo "Staged GitHub Release asset ${asset_name} differs from the attested candidate." | |
| exit 1 | |
| fi | |
| done | |
| emit_canonical_release "${created_state}" "${staged_assets}" | |
| - name: Transfer canonical release assets | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: canonical-release-assets | |
| path: | | |
| ./canonical/*.nupkg | |
| ./canonical/*.nupkg.sha256 | |
| ./canonical/*.nupkg.sigstore.json | |
| if-no-files-found: error | |
| overwrite: true | |
| retention-days: 7 | |
| publish_package: | |
| name: publish-package | |
| needs: [ validate, stage_release ] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| id-token: write # required only for NuGet Trusted Publishing | |
| steps: | |
| - name: Set up .NET 8 | |
| id: setup_dotnet | |
| uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 | |
| with: | |
| dotnet-version: 8.0.x | |
| dotnet-quality: ga | |
| # This privileged job deliberately has no checkout. Use an isolated, fixed global.json so | |
| # runner-installed newer SDKs cannot change the NuGet verification or publishing client. | |
| - name: Verify .NET 8 SDK selection | |
| shell: bash | |
| env: | |
| INSTALLED_DOTNET_SDK: ${{ steps.setup_dotnet.outputs.dotnet-version }} | |
| run: | | |
| if [[ ! "${INSTALLED_DOTNET_SDK}" =~ ^8\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "setup-dotnet returned an unexpected SDK version: ${INSTALLED_DOTNET_SDK}." | |
| exit 1 | |
| fi | |
| dotnet_sdk_workdir="${RUNNER_TEMP}/solsharp-dotnet-sdk" | |
| mkdir -p "${dotnet_sdk_workdir}" | |
| printf '%s\n' \ | |
| '{' \ | |
| ' "sdk": {' \ | |
| " \"version\": \"${INSTALLED_DOTNET_SDK}\"," \ | |
| ' "rollForward": "disable",' \ | |
| ' "allowPrerelease": false' \ | |
| ' }' \ | |
| '}' > "${dotnet_sdk_workdir}/global.json" | |
| selected_version="$(cd "${dotnet_sdk_workdir}" && dotnet --version)" | |
| if [[ "${selected_version}" != "${INSTALLED_DOTNET_SDK}" ]]; then | |
| echo "Expected .NET SDK ${INSTALLED_DOTNET_SDK}, but dotnet selected ${selected_version}." | |
| (cd "${dotnet_sdk_workdir}" && dotnet --info) | |
| exit 1 | |
| fi | |
| - name: Download canonical release assets | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: canonical-release-assets | |
| path: ./artifacts | |
| - name: Verify canonical release assets | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PACKAGE_NAME: ${{ needs.validate.outputs.package_name }} | |
| PACKAGE_SHA256: ${{ needs.stage_release.outputs.package_sha256 }} | |
| run: | | |
| package_path="./artifacts/${PACKAGE_NAME}" | |
| checksum_path="${package_path}.sha256" | |
| provenance_path="${package_path}.sigstore.json" | |
| actual_sha256="$(sha256sum "${package_path}" | cut -d ' ' -f 1)" | |
| if [[ "${actual_sha256}" != "${PACKAGE_SHA256}" ]]; then | |
| echo "Canonical package digest differs from the staged release output." | |
| exit 1 | |
| fi | |
| if ! printf '%s %s\n' "${actual_sha256}" "${PACKAGE_NAME}" | \ | |
| cmp --silent - "${checksum_path}"; then | |
| echo "Canonical checksum asset is invalid." | |
| exit 1 | |
| fi | |
| jq --exit-status \ | |
| --arg package_name "${PACKAGE_NAME}" \ | |
| --arg package_sha256 "${actual_sha256}" ' | |
| .mediaType == "application/vnd.dev.sigstore.bundle.v0.3+json" and | |
| .dsseEnvelope.payloadType == "application/vnd.in-toto+json" and | |
| ((.dsseEnvelope.payload | @base64d | fromjson) as $statement | | |
| $statement._type == "https://in-toto.io/Statement/v1" and | |
| $statement.predicateType == "https://slsa.dev/provenance/v1" and | |
| ($statement.subject | type) == "array" and | |
| ($statement.subject | length) == 1 and | |
| $statement.subject[0].name == $package_name and | |
| $statement.subject[0].digest.sha256 == $package_sha256) | |
| ' "${provenance_path}" > /dev/null | |
| gh attestation verify "${package_path}" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --bundle "${provenance_path}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \ | |
| --source-ref "${GITHUB_REF}" \ | |
| --source-digest "${GITHUB_SHA}" \ | |
| --deny-self-hosted-runners | |
| - name: Verify release tag target before publishing | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| api_version="2022-11-28" | |
| repo_api="/repos/${GITHUB_REPOSITORY}" | |
| encoded_tag="$(jq --null-input --raw-output \ | |
| --arg value "${GITHUB_REF_NAME}" '$value | @uri')" | |
| ref_state="${RUNNER_TEMP}/release-tag-ref.json" | |
| gh api \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/git/ref/tags/${encoded_tag}" \ | |
| > "${ref_state}" | |
| object_type="$(jq --exit-status --raw-output '.object.type' "${ref_state}")" | |
| object_sha="$(jq --exit-status --raw-output '.object.sha' "${ref_state}")" | |
| for _ in {1..9}; do | |
| case "${object_type}" in | |
| commit) | |
| if [[ "${object_sha}" != "${GITHUB_SHA}" ]]; then | |
| echo "Release tag ${GITHUB_REF_NAME} resolves to ${object_sha}, not ${GITHUB_SHA}." | |
| exit 1 | |
| fi | |
| exit 0 | |
| ;; | |
| tag) | |
| gh api \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/git/tags/${object_sha}" \ | |
| > "${ref_state}" | |
| object_type="$(jq --exit-status --raw-output '.object.type' "${ref_state}")" | |
| object_sha="$(jq --exit-status --raw-output '.object.sha' "${ref_state}")" | |
| ;; | |
| *) | |
| echo "Release tag resolves to unsupported Git object type ${object_type}." | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| echo "Release tag contains more than eight nested annotated tags." | |
| exit 1 | |
| # Trusted Publishing: exchange the GitHub OIDC token for a short-lived NuGet API key | |
| # (the jecacs/SolSharp policy authorizes this repo + this workflow file), then push with it. | |
| - name: NuGet login (OIDC) | |
| uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 | |
| id: login | |
| with: | |
| user: jecacs | |
| - name: Push to NuGet | |
| shell: bash | |
| env: | |
| PACKAGE_NAME: ${{ needs.validate.outputs.package_name }} | |
| run: | | |
| cd "${RUNNER_TEMP}/solsharp-dotnet-sdk" | |
| dotnet nuget push "${GITHUB_WORKSPACE}/artifacts/${PACKAGE_NAME}" \ | |
| --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \ | |
| --source https://api.nuget.org/v3/index.json \ | |
| --skip-duplicate | |
| # A retry may encounter an already-published version after NuGet accepted a previous push but | |
| # the workflow failed later. Never trust --skip-duplicate alone: the repository signature | |
| # records the SHA-2 digest of the exact unsigned nupkg accepted by NuGet, so require that digest | |
| # to match the canonical package recovered from the durable GitHub Release before continuing. | |
| - name: Verify published NuGet package | |
| shell: bash | |
| env: | |
| PACKAGE_NAME: ${{ needs.validate.outputs.package_name }} | |
| run: | | |
| service_index="${RUNNER_TEMP}/nuget-service-index.json" | |
| curl --fail --silent --show-error --location \ | |
| https://api.nuget.org/v3/index.json \ | |
| --output "${service_index}" | |
| package_base_address="$(jq --exit-status --raw-output ' | |
| first(.resources[] | select(."@type" == "PackageBaseAddress/3.0.0") | ."@id") | |
| ' "${service_index}")" | |
| package_version="${GITHUB_REF_NAME#v}" | |
| lower_version="${package_version,,}" | |
| published_url="${package_base_address}solsharp/${lower_version}/solsharp.${lower_version}.nupkg" | |
| published_package="${RUNNER_TEMP}/published-${PACKAGE_NAME}" | |
| for attempt in {1..30}; do | |
| if curl --fail --silent --show-error --location \ | |
| "${published_url}" --output "${published_package}"; then | |
| break | |
| fi | |
| if (( attempt == 30 )); then | |
| echo "The published package was not available from NuGet after 30 attempts." | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done | |
| (cd "${RUNNER_TEMP}/solsharp-dotnet-sdk" && \ | |
| dotnet nuget verify "${published_package}" --all) | |
| signature_file="${RUNNER_TEMP}/nuget-repository-signature.p7s" | |
| signature_properties="${RUNNER_TEMP}/nuget-repository-signature.properties" | |
| if ! unzip -p "${published_package}" .signature.p7s > "${signature_file}"; then | |
| echo "The package downloaded from NuGet has no repository signature." | |
| exit 1 | |
| fi | |
| openssl cms -verify -binary -inform DER \ | |
| -in "${signature_file}" -noverify -out "${signature_properties}" | |
| mapfile -t hash_records < <(tr -d '\r' < "${signature_properties}" | \ | |
| grep --extended-regexp '^[0-9.]+-Hash:[A-Za-z0-9+/]+=*$') | |
| if (( ${#hash_records[@]} != 1 )); then | |
| echo "The NuGet repository signature contains an unexpected package-hash record." | |
| exit 1 | |
| fi | |
| hash_oid="${hash_records[0]%%-Hash:*}" | |
| signed_hash="${hash_records[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 "The NuGet repository signature uses unsupported hash OID ${hash_oid}." | |
| exit 1 | |
| ;; | |
| esac | |
| local_hash="$(openssl dgst "-${hash_algorithm}" -binary \ | |
| "./artifacts/${PACKAGE_NAME}" | openssl base64 -A)" | |
| if [[ "${local_hash}" != "${signed_hash}" ]]; then | |
| echo "NuGet did not publish the exact locally validated package artifact." | |
| exit 1 | |
| fi | |
| release: | |
| name: publish-github-release | |
| needs: [ validate, stage_release, publish_package ] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # required only for completing the staged GitHub Release | |
| steps: | |
| - name: Download canonical release assets | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: canonical-release-assets | |
| path: ./artifacts | |
| - name: Verify canonical release assets | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PACKAGE_NAME: ${{ needs.validate.outputs.package_name }} | |
| PACKAGE_SHA256: ${{ needs.stage_release.outputs.package_sha256 }} | |
| run: | | |
| package_path="./artifacts/${PACKAGE_NAME}" | |
| checksum_path="${package_path}.sha256" | |
| provenance_path="${package_path}.sigstore.json" | |
| actual_sha256="$(sha256sum "${package_path}" | cut -d ' ' -f 1)" | |
| if [[ "${actual_sha256}" != "${PACKAGE_SHA256}" ]]; then | |
| echo "Canonical package digest differs from the staged release output." | |
| exit 1 | |
| fi | |
| if ! printf '%s %s\n' "${actual_sha256}" "${PACKAGE_NAME}" | \ | |
| cmp --silent - "${checksum_path}"; then | |
| echo "Canonical checksum asset is invalid." | |
| exit 1 | |
| fi | |
| jq --exit-status \ | |
| --arg package_name "${PACKAGE_NAME}" \ | |
| --arg package_sha256 "${actual_sha256}" ' | |
| .mediaType == "application/vnd.dev.sigstore.bundle.v0.3+json" and | |
| .dsseEnvelope.payloadType == "application/vnd.in-toto+json" and | |
| ((.dsseEnvelope.payload | @base64d | fromjson) as $statement | | |
| $statement._type == "https://in-toto.io/Statement/v1" and | |
| $statement.predicateType == "https://slsa.dev/provenance/v1" and | |
| ($statement.subject | type) == "array" and | |
| ($statement.subject | length) == 1 and | |
| $statement.subject[0].name == $package_name and | |
| $statement.subject[0].digest.sha256 == $package_sha256) | |
| ' "${provenance_path}" > /dev/null | |
| gh attestation verify "${package_path}" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --bundle "${provenance_path}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \ | |
| --source-ref "${GITHUB_REF}" \ | |
| --source-digest "${GITHUB_SHA}" \ | |
| --deny-self-hosted-runners | |
| - name: Publish staged GitHub Release | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PACKAGE_NAME: ${{ needs.validate.outputs.package_name }} | |
| PACKAGE_SHA256: ${{ needs.stage_release.outputs.package_sha256 }} | |
| RELEASE_ID: ${{ needs.stage_release.outputs.release_id }} | |
| run: | | |
| expected_title="SolSharp ${GITHUB_REF_NAME#v}" | |
| expected_prerelease=false | |
| if [[ "${GITHUB_REF_NAME}" == *-* ]]; then | |
| expected_prerelease=true | |
| fi | |
| api_version="2022-11-28" | |
| repo_api="/repos/${GITHUB_REPOSITORY}" | |
| encoded_tag="$(jq --null-input --raw-output \ | |
| --arg value "${GITHUB_REF_NAME}" '$value | @uri')" | |
| release_state="${RUNNER_TEMP}/release-state.json" | |
| if [[ ! "${RELEASE_ID}" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "Staging did not return a valid GitHub Release ID." | |
| exit 1 | |
| fi | |
| verify_tag_target() { | |
| local ref_state="${RUNNER_TEMP}/release-tag-ref.json" | |
| gh api \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/git/ref/tags/${encoded_tag}" \ | |
| > "${ref_state}" | |
| local object_type object_sha | |
| object_type="$(jq --exit-status --raw-output '.object.type' "${ref_state}")" | |
| object_sha="$(jq --exit-status --raw-output '.object.sha' "${ref_state}")" | |
| for _ in {1..9}; do | |
| case "${object_type}" in | |
| commit) | |
| if [[ "${object_sha}" != "${GITHUB_SHA}" ]]; then | |
| echo "Release tag ${GITHUB_REF_NAME} resolves to ${object_sha}, not ${GITHUB_SHA}." | |
| return 1 | |
| fi | |
| return 0 | |
| ;; | |
| tag) | |
| gh api \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/git/tags/${object_sha}" \ | |
| > "${ref_state}" | |
| object_type="$(jq --exit-status --raw-output '.object.type' "${ref_state}")" | |
| object_sha="$(jq --exit-status --raw-output '.object.sha' "${ref_state}")" | |
| ;; | |
| *) | |
| echo "Release tag resolves to unsupported Git object type ${object_type}." | |
| return 1 | |
| ;; | |
| esac | |
| done | |
| echo "Release tag contains more than eight nested annotated tags." | |
| return 1 | |
| } | |
| get_published_release() { | |
| local output_file="$1" | |
| local status | |
| if ! status="$(curl --silent --show-error \ | |
| --header 'Accept: application/vnd.github+json' \ | |
| --header "Authorization: Bearer ${GH_TOKEN}" \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| --output "${output_file}" \ | |
| --write-out '%{http_code}' \ | |
| "https://api.github.com${repo_api}/releases/tags/${encoded_tag}")"; then | |
| echo "GitHub's published-release lookup failed." | |
| return 2 | |
| fi | |
| case "${status}" in | |
| 200) return 0 ;; | |
| 404) return 1 ;; | |
| *) | |
| echo "GitHub's published-release lookup returned HTTP ${status}." | |
| cat "${output_file}" | |
| return 2 | |
| ;; | |
| esac | |
| } | |
| read_release_by_id() { | |
| local release_id="$1" | |
| local output_file="$2" | |
| local metadata_file asset_pages | |
| metadata_file="$(mktemp "${RUNNER_TEMP}/solsharp-release-metadata.XXXXXX")" | |
| asset_pages="$(mktemp "${RUNNER_TEMP}/solsharp-release-asset-pages.XXXXXX")" | |
| gh api \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/releases/${release_id}" \ | |
| > "${metadata_file}" | |
| gh api --paginate --slurp \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| "${repo_api}/releases/${release_id}/assets?per_page=100" \ | |
| > "${asset_pages}" | |
| jq --slurpfile asset_pages "${asset_pages}" \ | |
| '.assets = (($asset_pages[0] | add) // [])' \ | |
| "${metadata_file}" > "${output_file}" | |
| } | |
| verify_release_state() { | |
| local state_file="$1" | |
| local expected_draft="$2" | |
| if ! jq --exit-status \ | |
| --arg package_name "${PACKAGE_NAME}" \ | |
| --arg release_name "${expected_title}" \ | |
| --arg tag_name "${GITHUB_REF_NAME}" \ | |
| --argjson release_id "${RELEASE_ID}" \ | |
| --argjson is_draft "${expected_draft}" \ | |
| --argjson is_prerelease "${expected_prerelease}" ' | |
| .id == $release_id and | |
| .tag_name == $tag_name and | |
| .name == $release_name and | |
| .author.login == "github-actions[bot]" and | |
| .draft == $is_draft and | |
| .prerelease == $is_prerelease and | |
| (.assets | type) == "array" and | |
| (.assets | length) == 3 and | |
| ([.assets[].name] | sort) == ([ | |
| $package_name, | |
| ($package_name + ".sha256"), | |
| ($package_name + ".sigstore.json") | |
| ] | sort) | |
| ' "${state_file}" > /dev/null; then | |
| echo "GitHub Release state does not match the exact staged release." | |
| jq '{ id, tag_name, target_commitish, name, author: .author.login, draft, prerelease, | |
| assets: [.assets[].name] }' "${state_file}" | |
| return 1 | |
| fi | |
| if ! jq --exit-status ' | |
| all(.assets[]; | |
| (.id | type) == "number" and | |
| (.size | type) == "number" and .size > 0 and | |
| .state == "uploaded" and | |
| (.digest | type) == "string" and | |
| (.digest | test("^sha256:[0-9a-f]{64}$")) and | |
| .uploader.login == "github-actions[bot]") | |
| ' "${state_file}" > /dev/null; then | |
| echo "GitHub Release asset metadata is invalid." | |
| return 1 | |
| fi | |
| } | |
| verify_downloaded_assets() { | |
| local asset_directory="$1" | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| if [[ ! -f "${asset_directory}/${asset_name}" || \ | |
| -L "${asset_directory}/${asset_name}" ]]; then | |
| echo "GitHub Release is missing required regular asset ${asset_name}." | |
| return 1 | |
| fi | |
| if ! cmp --silent \ | |
| "./artifacts/${asset_name}" \ | |
| "${asset_directory}/${asset_name}"; then | |
| echo "GitHub Release asset ${asset_name} differs from the canonical staged artifact." | |
| return 1 | |
| fi | |
| done | |
| local package_path="${asset_directory}/${PACKAGE_NAME}" | |
| local provenance_path="${package_path}.sigstore.json" | |
| local actual_sha256 | |
| actual_sha256="$(sha256sum "${package_path}" | cut -d ' ' -f 1)" | |
| if [[ "${actual_sha256}" != "${PACKAGE_SHA256}" ]]; then | |
| echo "Downloaded GitHub Release package has an unexpected digest." | |
| return 1 | |
| fi | |
| if ! printf '%s %s\n' "${actual_sha256}" "${PACKAGE_NAME}" | \ | |
| cmp --silent - "${package_path}.sha256"; then | |
| echo "Downloaded GitHub Release checksum asset is invalid." | |
| return 1 | |
| fi | |
| if ! jq --exit-status \ | |
| --arg package_name "${PACKAGE_NAME}" \ | |
| --arg package_sha256 "${actual_sha256}" ' | |
| .mediaType == "application/vnd.dev.sigstore.bundle.v0.3+json" and | |
| .dsseEnvelope.payloadType == "application/vnd.in-toto+json" and | |
| ((.dsseEnvelope.payload | @base64d | fromjson) as $statement | | |
| $statement._type == "https://in-toto.io/Statement/v1" and | |
| $statement.predicateType == "https://slsa.dev/provenance/v1" and | |
| ($statement.subject | type) == "array" and | |
| ($statement.subject | length) == 1 and | |
| $statement.subject[0].name == $package_name and | |
| $statement.subject[0].digest.sha256 == $package_sha256) | |
| ' "${provenance_path}" > /dev/null; then | |
| echo "Downloaded GitHub Release provenance is invalid." | |
| return 1 | |
| fi | |
| gh attestation verify "${package_path}" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --bundle "${provenance_path}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \ | |
| --source-ref "${GITHUB_REF}" \ | |
| --source-digest "${GITHUB_SHA}" \ | |
| --deny-self-hosted-runners | |
| } | |
| download_and_verify_release() { | |
| local state_file="$1" | |
| local asset_directory | |
| asset_directory="$(mktemp -d "${RUNNER_TEMP}/solsharp-release-assets.XXXXXX")" | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| local asset_id | |
| asset_id="$(jq --exit-status --raw-output \ | |
| --arg asset_name "${asset_name}" ' | |
| [.assets[] | select(.name == $asset_name) | .id] | | |
| if length == 1 and (.[0] | type) == "number" | |
| then .[0] | |
| else error("unexpected release asset set") | |
| end | |
| ' "${state_file}")" | |
| curl --fail --silent --show-error --location \ | |
| --header 'Accept: application/octet-stream' \ | |
| --header "Authorization: Bearer ${GH_TOKEN}" \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| --output "${asset_directory}/${asset_name}" \ | |
| "https://api.github.com${repo_api}/releases/assets/${asset_id}" | |
| done | |
| local actual_digest api_digest | |
| for asset_name in \ | |
| "${PACKAGE_NAME}" \ | |
| "${PACKAGE_NAME}.sha256" \ | |
| "${PACKAGE_NAME}.sigstore.json"; do | |
| actual_digest="sha256:$(sha256sum "${asset_directory}/${asset_name}" | cut -d ' ' -f 1)" | |
| api_digest="$(jq --exit-status --raw-output \ | |
| --arg asset_name "${asset_name}" ' | |
| [.assets[] | select(.name == $asset_name) | .digest] | | |
| if length == 1 then .[0] else error("unexpected release asset set") end | |
| ' "${state_file}")" | |
| if [[ "${actual_digest}" != "${api_digest}" ]]; then | |
| echo "Downloaded release asset ${asset_name} differs from GitHub's recorded digest." | |
| return 1 | |
| fi | |
| done | |
| verify_downloaded_assets "${asset_directory}" | |
| } | |
| accept_published_release() { | |
| local tag_state_file="$1" | |
| local release_id state_file | |
| release_id="$(jq --exit-status --raw-output \ | |
| 'if (.id | type) == "number" then .id else error("missing release id") end' \ | |
| "${tag_state_file}")" | |
| state_file="$(mktemp "${RUNNER_TEMP}/solsharp-published-state.XXXXXX")" | |
| read_release_by_id "${release_id}" "${state_file}" | |
| verify_release_state "${state_file}" false | |
| download_and_verify_release "${state_file}" | |
| } | |
| verify_tag_target | |
| read_release_by_id "${RELEASE_ID}" "${release_state}" | |
| current_draft="$(jq --exit-status --raw-output \ | |
| 'if (.draft | type) == "boolean" then .draft else error("missing draft state") end' \ | |
| "${release_state}")" | |
| if [[ "${current_draft}" == "false" ]]; then | |
| accept_published_release "${release_state}" | |
| published_state="${RUNNER_TEMP}/published-release.json" | |
| if get_published_release "${published_state}"; then | |
| accept_published_release "${published_state}" | |
| exit 0 | |
| else | |
| lookup_status=$? | |
| if (( lookup_status == 1 )); then | |
| echo "Exact release ${RELEASE_ID} is public but is absent from the tag endpoint." | |
| fi | |
| exit "${lookup_status}" | |
| fi | |
| fi | |
| verify_release_state "${release_state}" true | |
| download_and_verify_release "${release_state}" | |
| make_latest="$(jq --exit-status --raw-output ' | |
| if .prerelease == true then "false" | |
| elif .prerelease == false then "legacy" | |
| else error("missing prerelease state") | |
| end | |
| ' "${release_state}")" | |
| # Check the authoritative published-by-tag endpoint immediately before mutating the exact | |
| # staged release ID. If it became public concurrently, only that same verified ID is accepted. | |
| verify_tag_target | |
| published_state="${RUNNER_TEMP}/published-release.json" | |
| if get_published_release "${published_state}"; then | |
| accept_published_release "${published_state}" | |
| exit 0 | |
| else | |
| lookup_status=$? | |
| if (( lookup_status != 1 )); then | |
| exit "${lookup_status}" | |
| fi | |
| fi | |
| verify_tag_target | |
| gh api --method PATCH \ | |
| --header "X-GitHub-Api-Version: ${api_version}" \ | |
| --field draft=false \ | |
| --raw-field make_latest="${make_latest}" \ | |
| "${repo_api}/releases/${RELEASE_ID}" \ | |
| > "${RUNNER_TEMP}/publish-release-response.json" | |
| read_release_by_id "${RELEASE_ID}" "${release_state}" | |
| verify_release_state "${release_state}" false | |
| download_and_verify_release "${release_state}" | |
| # The exact-ID response is authoritative for mutation; the tag endpoint must converge to | |
| # that same verified release before the workflow reports success. | |
| for attempt in {1..6}; do | |
| if get_published_release "${published_state}"; then | |
| accept_published_release "${published_state}" | |
| exit 0 | |
| else | |
| lookup_status=$? | |
| fi | |
| if (( lookup_status != 1 || attempt == 6 )); then | |
| echo "Published release ${RELEASE_ID} did not become authoritative for ${GITHUB_REF_NAME}." | |
| exit "${lookup_status}" | |
| fi | |
| sleep 2 | |
| done |