diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2de7121..cc86f4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: permissions: contents: read + actions: read security-events: read pull-requests: read @@ -358,66 +359,10 @@ jobs: dotnet-version: | 8.0.x 10.0.102 - - name: Download CI Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-preflight - path: artifacts/ci/preflight - - name: Download Build Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-build - path: artifacts/ci/build - - name: Download API Contract Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-api-contract - path: artifacts/ci/api-contract - - name: Download Pack Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-pack - path: artifacts/ci/pack - - name: Download Consumer Smoke Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-consumer-smoke - path: artifacts/ci/consumer-smoke - - name: Download Package-Backed Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-package-backed-tests - path: artifacts/ci/package-backed-tests - - name: Download Security Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-security-nuget - path: artifacts/ci/security-nuget - - name: Download Docs Link Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-docs-links-full - path: artifacts/ci/docs-links-full - - name: Download Naming Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-naming-snt - path: artifacts/ci/naming-snt - - name: Download Versioning Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-versioning-svt - path: artifacts/ci/versioning-svt - - name: Download Version Convergence Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-version-convergence - path: artifacts/ci/version-convergence - - name: Download Test Artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ci-tests-bdd-coverage - path: artifacts/ci/tests-bdd-coverage + - name: Download Required Artifacts (retry/backoff) + env: + GH_TOKEN: ${{ github.token }} + run: bash tools/ci/bin/download_summary_artifacts.sh "${GITHUB_RUN_ID}" - name: Run Entry Check run: bash -euo pipefail tools/ci/bin/run.sh summary - name: Upload Artifact diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef315a1..35e688b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,7 +223,7 @@ jobs: with: name: nuget-attestation-verify path: artifacts/nuget/attestation-verify.txt - if-no-files-found: error + if-no-files-found: warn - name: Gate 4 - SVT post-publish (git version == package version == nuget version) shell: bash diff --git a/tools/ci/bin/download_artifacts_with_retry.sh b/tools/ci/bin/download_artifacts_with_retry.sh new file mode 100755 index 0000000..8a29e65 --- /dev/null +++ b/tools/ci/bin/download_artifacts_with_retry.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' >&2 +Usage: download_artifacts_with_retry.sh [...] + +Environment: + GITHUB_REPOSITORY Required (owner/repo). + ARTIFACT_DOWNLOAD_MAX_ATTEMPTS Optional, default: 6 + ARTIFACT_DOWNLOAD_INITIAL_DELAY Optional, default: 2 (seconds) +EOF +} + +if [[ $# -lt 2 ]]; then + usage + exit 2 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh CLI is required." >&2 + exit 1 +fi + +run_id="$1" +shift + +repo="${GITHUB_REPOSITORY:-}" +if [[ -z "${repo}" ]]; then + echo "ERROR: GITHUB_REPOSITORY is required." >&2 + exit 1 +fi + +max_attempts="${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS:-6}" +initial_delay="${ARTIFACT_DOWNLOAD_INITIAL_DELAY:-2}" + +if [[ ! "${max_attempts}" =~ ^[0-9]+$ || "${max_attempts}" -lt 1 ]]; then + echo "ERROR: ARTIFACT_DOWNLOAD_MAX_ATTEMPTS must be >= 1." >&2 + exit 1 +fi +if [[ ! "${initial_delay}" =~ ^[0-9]+$ || "${initial_delay}" -lt 1 ]]; then + echo "ERROR: ARTIFACT_DOWNLOAD_INITIAL_DELAY must be >= 1." >&2 + exit 1 +fi + +download_one() { + local name="$1" + local dest="$2" + local attempt=1 + local delay="${initial_delay}" + + while (( attempt <= max_attempts )); do + echo "INFO: Download '${name}' attempt ${attempt}/${max_attempts} -> ${dest}" + rm -rf "${dest}" + mkdir -p "${dest}" + + if gh run download "${run_id}" --repo "${repo}" -n "${name}" -D "${dest}" >/tmp/artifact-download.log 2>&1; then + return 0 + fi + + if (( attempt == max_attempts )); then + echo "ERROR: Download '${name}' failed after ${max_attempts} attempts." >&2 + cat /tmp/artifact-download.log >&2 || true + return 1 + fi + + echo "WARN: Download '${name}' failed. Retrying in ${delay}s." >&2 + cat /tmp/artifact-download.log >&2 || true + sleep "${delay}" + delay=$((delay * 2)) + attempt=$((attempt + 1)) + done + + return 1 +} + +for spec in "$@"; do + if [[ "${spec}" != *=* ]]; then + echo "ERROR: Invalid artifact mapping '${spec}'. Expected ." >&2 + exit 1 + fi + name="${spec%%=*}" + dest="${spec#*=}" + if [[ -z "${name}" || -z "${dest}" ]]; then + echo "ERROR: Invalid artifact mapping '${spec}'. Expected non-empty name and destination." >&2 + exit 1 + fi + + download_one "${name}" "${dest}" +done + +echo "OK: all artifacts downloaded with retry policy." diff --git a/tools/ci/bin/download_summary_artifacts.sh b/tools/ci/bin/download_summary_artifacts.sh new file mode 100755 index 0000000..5ac1107 --- /dev/null +++ b/tools/ci/bin/download_summary_artifacts.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +if [[ "$#" -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +run_id="$1" + +bash "${SCRIPT_DIR}/download_artifacts_with_retry.sh" "${run_id}" \ + "ci-preflight=artifacts/ci/preflight" \ + "ci-build=artifacts/ci/build" \ + "ci-api-contract=artifacts/ci/api-contract" \ + "ci-pack=artifacts/ci/pack" \ + "ci-consumer-smoke=artifacts/ci/consumer-smoke" \ + "ci-package-backed-tests=artifacts/ci/package-backed-tests" \ + "ci-security-nuget=artifacts/ci/security-nuget" \ + "ci-docs-links-full=artifacts/ci/docs-links-full" \ + "ci-naming-snt=artifacts/ci/naming-snt" \ + "ci-versioning-svt=artifacts/ci/versioning-svt" \ + "ci-version-convergence=artifacts/ci/version-convergence" \ + "ci-tests-bdd-coverage=artifacts/ci/tests-bdd-coverage" diff --git a/tools/ci/release/gate4_verify_postpublish.sh b/tools/ci/release/gate4_verify_postpublish.sh index 53410a3..3c908f4 100755 --- a/tools/ci/release/gate4_verify_postpublish.sh +++ b/tools/ci/release/gate4_verify_postpublish.sh @@ -33,11 +33,21 @@ retry_sleep_seconds="${SVT_POSTPUBLISH_RETRY_SLEEP_SECONDS:-10}" if [[ -n "${SVT_POSTPUBLISH_REQUIRE_REGISTRATION:-}" ]]; then require_registration="${SVT_POSTPUBLISH_REQUIRE_REGISTRATION}" else - if [[ "${is_prerelease}" == "1" ]]; then - require_registration="0" - else - require_registration="1" - fi + # Registration endpoint is typically the slowest to converge and caused repeated + # false negatives. Gate 4 now validates publish availability via V2 download. + require_registration="0" +fi + +if [[ -n "${SVT_POSTPUBLISH_REQUIRE_FLATCONTAINER:-}" ]]; then + require_flatcontainer="${SVT_POSTPUBLISH_REQUIRE_FLATCONTAINER}" +else + require_flatcontainer="0" +fi + +if [[ -n "${SVT_POSTPUBLISH_REQUIRE_V2_DOWNLOAD:-}" ]]; then + require_v2_download="${SVT_POSTPUBLISH_REQUIRE_V2_DOWNLOAD}" +else + require_v2_download="1" fi if [[ ! "${retry_count}" =~ ^[0-9]+$ ]]; then @@ -56,6 +66,14 @@ if [[ "${require_registration}" != "0" && "${require_registration}" != "1" ]]; t echo "SVT_POSTPUBLISH_REQUIRE_REGISTRATION must be 0 or 1 (actual='${require_registration}')" >&2 exit 1 fi +if [[ "${require_flatcontainer}" != "0" && "${require_flatcontainer}" != "1" ]]; then + echo "SVT_POSTPUBLISH_REQUIRE_FLATCONTAINER must be 0 or 1 (actual='${require_flatcontainer}')" >&2 + exit 1 +fi +if [[ "${require_v2_download}" != "0" && "${require_v2_download}" != "1" ]]; then + echo "SVT_POSTPUBLISH_REQUIRE_V2_DOWNLOAD must be 0 or 1 (actual='${require_v2_download}')" >&2 + exit 1 +fi EXPECTED_VERSION="${expected_version}" \ NUPKG_PATH="${nupkg_path}" \ @@ -64,5 +82,6 @@ RETRY_SLEEP_SECONDS="${retry_sleep_seconds}" \ RETRY_SCHEDULE_SECONDS="${retry_schedule_seconds}" \ REQUIRE_SEARCH=0 \ REQUIRE_REGISTRATION="${require_registration}" \ -REQUIRE_FLATCONTAINER=1 \ +REQUIRE_FLATCONTAINER="${require_flatcontainer}" \ +REQUIRE_V2_DOWNLOAD="${require_v2_download}" \ bash tools/ci/verify_nuget_release.sh diff --git a/tools/ci/release/upsert_github_release.sh b/tools/ci/release/upsert_github_release.sh index 1bfdda1..c89126c 100755 --- a/tools/ci/release/upsert_github_release.sh +++ b/tools/ci/release/upsert_github_release.sh @@ -3,6 +3,8 @@ set -euo pipefail RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG is required}" REPO="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +MAX_ATTEMPTS="${RELEASE_GH_MAX_ATTEMPTS:-5}" +INITIAL_DELAY_SECONDS="${RELEASE_GH_INITIAL_DELAY_SECONDS:-2}" version="${RELEASE_TAG#v}" prerelease_flag=() @@ -21,15 +23,74 @@ else latest_edit_flag+=(--latest) fi +if [[ ! "${MAX_ATTEMPTS}" =~ ^[0-9]+$ || "${MAX_ATTEMPTS}" -lt 1 ]]; then + echo "MAX_ATTEMPTS must be a positive integer (actual='${MAX_ATTEMPTS}')" >&2 + exit 1 +fi +if [[ ! "${INITIAL_DELAY_SECONDS}" =~ ^[0-9]+$ || "${INITIAL_DELAY_SECONDS}" -lt 1 ]]; then + echo "INITIAL_DELAY_SECONDS must be a positive integer (actual='${INITIAL_DELAY_SECONDS}')" >&2 + exit 1 +fi + +classify_reason() { + local msg="$1" + if grep -qiE "401|bad credentials|requires authentication|unauthorized" <<<"${msg}"; then + echo "auth" + return + fi + if grep -qiE "429|rate limit|secondary rate limit" <<<"${msg}"; then + echo "rate-limit" + return + fi + if grep -qiE "5[0-9][0-9]|internal server error|gateway timeout|bad gateway|service unavailable" <<<"${msg}"; then + echo "5xx" + return + fi + if grep -qiE "timed out|timeout|connection reset|connection refused|temporary failure|network" <<<"${msg}"; then + echo "network" + return + fi + echo "unknown" +} + +run_with_retry() { + local attempt=1 + local delay="${INITIAL_DELAY_SECONDS}" + local cmd=("$@") + local output="" + while (( attempt <= MAX_ATTEMPTS )); do + output="$("${cmd[@]}" 2>&1)" && { + if [[ -n "${output}" ]]; then + printf '%s\n' "${output}" + fi + return 0 + } + + local reason + reason="$(classify_reason "${output}")" + echo "WARN: release API call failed (attempt ${attempt}/${MAX_ATTEMPTS}, reason=${reason})." >&2 + printf '%s\n' "${output}" >&2 + + if [[ "${reason}" == "auth" || "${attempt}" -ge "${MAX_ATTEMPTS}" ]]; then + echo "FAIL: release API call failed (reason=${reason})." >&2 + return 1 + fi + + sleep "${delay}" + delay=$((delay * 2)) + attempt=$((attempt + 1)) + done +} + if gh release view "${RELEASE_TAG}" --repo "${REPO}" >/dev/null 2>&1; then - gh release edit "${RELEASE_TAG}" --repo "${REPO}" --title "${RELEASE_TAG}" "${prerelease_flag[@]}" "${latest_edit_flag[@]}" + run_with_retry gh release edit "${RELEASE_TAG}" --repo "${REPO}" --title "${RELEASE_TAG}" "${prerelease_flag[@]}" "${latest_edit_flag[@]}" # gh does not support --latest=false for `release edit`. If an existing RC was # accidentally marked as Latest, correct it via API. if [[ "${is_rc}" == "true" ]]; then - release_id="$(gh api -H "Accept: application/vnd.github+json" "repos/${REPO}/releases/tags/${RELEASE_TAG}" --jq .id)" - gh api -X PATCH -H "Accept: application/vnd.github+json" "repos/${REPO}/releases/${release_id}" -f make_latest=false >/dev/null + release_id="$(run_with_retry gh api -H "Accept: application/vnd.github+json" "repos/${REPO}/releases/tags/${RELEASE_TAG}" --jq .id)" + run_with_retry gh api -X PATCH -H "Accept: application/vnd.github+json" "repos/${REPO}/releases/${release_id}" -f make_latest=false >/dev/null fi else - gh release create "${RELEASE_TAG}" --repo "${REPO}" --title "${RELEASE_TAG}" --notes "Automated tag-only release for ${version}" "${prerelease_flag[@]}" "${latest_create_flag[@]}" + run_with_retry gh release create "${RELEASE_TAG}" --repo "${REPO}" --title "${RELEASE_TAG}" --notes "Automated tag-only release for ${version}" "${prerelease_flag[@]}" "${latest_create_flag[@]}" fi diff --git a/tools/ci/verify_nuget_release.sh b/tools/ci/verify_nuget_release.sh index 8e784e1..70d7d68 100755 --- a/tools/ci/verify_nuget_release.sh +++ b/tools/ci/verify_nuget_release.sh @@ -16,13 +16,16 @@ VERIFY_ONLINE="${VERIFY_ONLINE:-1}" REQUIRE_SEARCH="${REQUIRE_SEARCH:-1}" REQUIRE_REGISTRATION="${REQUIRE_REGISTRATION:-1}" REQUIRE_FLATCONTAINER="${REQUIRE_FLATCONTAINER:-1}" +REQUIRE_V2_DOWNLOAD="${REQUIRE_V2_DOWNLOAD:-0}" SEARCH_OK="skipped" REGISTRATION_OK="skipped" FLATCONTAINER_OK="skipped" +V2_DOWNLOAD_OK="skipped" REGISTRATION_URL="" SEARCH_URL="" FLAT_URL="" +V2_URL="" fail() { echo "FAIL: $*" >&2 @@ -287,6 +290,19 @@ query_flatcontainer() { return 1 } +query_v2_download() { + local pkg_id_lc + pkg_id_lc="$(printf '%s' "${PKG_ID}" | tr '[:upper:]' '[:lower:]')" + V2_URL="https://www.nuget.org/api/v2/package/${pkg_id_lc}/${PKG_VER}" + local status + status="$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time "${TIMEOUT_SECONDS}" "${V2_URL}" || true)" + if [[ "${status}" == "200" ]]; then + V2_DOWNLOAD_OK="ok" + return 0 + fi + return 1 +} + emit_summary_json() { python3 - <<'PY' import json @@ -300,10 +316,12 @@ print(json.dumps({ "require_search": os.environ.get("REQUIRE_SEARCH", ""), "require_registration": os.environ.get("REQUIRE_REGISTRATION", ""), "require_flatcontainer": os.environ.get("REQUIRE_FLATCONTAINER", ""), + "require_v2_download": os.environ.get("REQUIRE_V2_DOWNLOAD", ""), "registration": os.environ.get("REGISTRATION_URL", ""), "search": os.environ.get("SEARCH_OK", "skipped"), "registration_check": os.environ.get("REGISTRATION_OK", "skipped"), - "flatcontainer": os.environ.get("FLATCONTAINER_OK", "skipped") + "flatcontainer": os.environ.get("FLATCONTAINER_OK", "skipped"), + "v2_download": os.environ.get("V2_DOWNLOAD_OK", "skipped") }, separators=(",", ":"))) PY } @@ -321,6 +339,7 @@ main() { require_bool_flag "REQUIRE_SEARCH" "${REQUIRE_SEARCH}" require_bool_flag "REQUIRE_REGISTRATION" "${REQUIRE_REGISTRATION}" require_bool_flag "REQUIRE_FLATCONTAINER" "${REQUIRE_FLATCONTAINER}" + require_bool_flag "REQUIRE_V2_DOWNLOAD" "${REQUIRE_V2_DOWNLOAD}" if [[ -n "${PKG_ID}" || -n "${PKG_VER}" ]]; then if [[ -z "${PKG_ID}" || -z "${PKG_VER}" ]]; then @@ -398,11 +417,18 @@ main() { else info "Flatcontainer check skipped (REQUIRE_FLATCONTAINER=${REQUIRE_FLATCONTAINER})." fi + + if [[ "${REQUIRE_V2_DOWNLOAD}" == "1" ]]; then + retry_network "v2-download" query_v2_download + info "V2 download check OK: ${V2_URL}" + else + info "V2 download check skipped (REQUIRE_V2_DOWNLOAD=${REQUIRE_V2_DOWNLOAD})." + fi else info "Online checks skipped (VERIFY_ONLINE=${VERIFY_ONLINE})." fi - export PKG_ID PKG_VER EXPECTED_VERSION VERIFY_ONLINE REQUIRE_SEARCH REQUIRE_REGISTRATION REQUIRE_FLATCONTAINER REGISTRATION_URL SEARCH_OK REGISTRATION_OK FLATCONTAINER_OK + export PKG_ID PKG_VER EXPECTED_VERSION VERIFY_ONLINE REQUIRE_SEARCH REQUIRE_REGISTRATION REQUIRE_FLATCONTAINER REQUIRE_V2_DOWNLOAD REGISTRATION_URL SEARCH_OK REGISTRATION_OK FLATCONTAINER_OK V2_DOWNLOAD_OK emit_summary_json echo "OK: verify_nuget_release completed." }