Skip to content

fix(ci): decouple releases from the devnet faucet (#12) #15

fix(ci): decouple releases from the devnet faucet (#12)

fix(ci): decouple releases from the devnet faucet (#12) #15

Workflow file for this run

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