diff --git a/.github/scripts/purge-jsdelivr.sh b/.github/scripts/purge-jsdelivr.sh index 34a5fe6..0148a2c 100755 --- a/.github/scripts/purge-jsdelivr.sh +++ b/.github/scripts/purge-jsdelivr.sh @@ -1,10 +1,17 @@ #!/usr/bin/env bash -# Purges @latest and @main for each file given. -set -euo pipefail +# Purges @latest for each file given and prints the files whose purge failed. A throttled purge still +# answers HTTP 200, so the body decides. +set -uo pipefail +failed=0 for f in "$@"; do - for ref in latest main; do - url="https://purge.jsdelivr.net/gh/${GITHUB_REPOSITORY}@${ref}/${f}" - echo "Purging ${url}" - curl -sfS --retry 3 "${url}" > /dev/null || { echo "::error::Purge failed for ${url}"; exit 1; } - done + url="https://purge.jsdelivr.net/gh/${GITHUB_REPOSITORY}@latest/${f}" + body=$(curl -sfS --max-time 30 --retry 3 --retry-max-time 60 "${url}") || body="" + if jq -e '[.paths[] | .throttled == false and ([.providers[]] | all)] | all' <<< "${body}" > /dev/null 2>&1; then + echo "Purged ${url}" >&2 + else + echo "::error::Purge failed or was throttled for ${url}: ${body:-no response}" >&2 + echo "${f}" + failed=1 + fi done +exit "${failed}" diff --git a/.github/scripts/stale-jsdelivr-files.sh b/.github/scripts/stale-jsdelivr-files.sh new file mode 100755 index 0000000..7eccafb --- /dev/null +++ b/.github/scripts/stale-jsdelivr-files.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Prints each file whose @latest copy is not version $1. +set -uo pipefail +version=$1 +shift +for f in "$@"; do + v=$(curl -sfSI --max-time 20 "https://cdn.jsdelivr.net/gh/${GITHUB_REPOSITORY}@latest/${f}" \ + | tr -d '\r' | awk -F': ' 'tolower($1) == "x-jsd-version" { print $2 }') || v="" + echo "${f}: ${v:-unavailable}" >&2 + [ "${v}" = "${version}" ] || echo "${f}" +done diff --git a/.github/workflows/release-and-purge.yml b/.github/workflows/release-and-purge.yml index f2e444e..ab0d995 100644 --- a/.github/workflows/release-and-purge.yml +++ b/.github/workflows/release-and-purge.yml @@ -31,11 +31,12 @@ env: FILES: surface_tag.js surface_tag.min.js surface_embed_v1.js surface_embed_v1.min.js jobs: - release-and-purge: + release: runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.release.outputs.version }} steps: - - uses: actions/checkout@v4 - # jsDelivr @latest resolves to the highest SEMVER release, and this repo already uses v1.1.x — so # bump the PATCH of the latest release (v1.1.8 -> v1.1.9). Date-based tags would break @latest: # leading zeros (2026.09.02) are not valid semver, so jsDelivr would ignore them. target = the exact @@ -79,41 +80,55 @@ jobs: -f target_commitish="${GITHUB_SHA}" \ -f name="${RELEASE_TAG}" \ -f body="Surface Tag synced from surface-forms (packages/surface-scripts)." - echo "Released ${RELEASE_TAG}; jsDelivr @latest now resolves to this commit." + echo "Released ${RELEASE_TAG} at ${GITHUB_SHA}." | tee -a "${GITHUB_STEP_SUMMARY}" echo "version=${RELEASE_TAG#v}" >> "${GITHUB_OUTPUT}" - # An edge that refills between the purge and jsDelivr picking up the new release caches the - # previous version for 12 hours. Purge, re-purge until every @latest file reports the new - # version, then purge once more. + # A separate job, so "Re-run failed jobs" repeats the purge without cutting another release. + purge: + needs: release + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + VERSION: ${{ needs.release.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Purge jsDelivr cache - run: .github/scripts/purge-jsdelivr.sh ${FILES} + id: purge + run: | + failed=$(.github/scripts/purge-jsdelivr.sh ${FILES} | xargs) || true + echo "failed=${failed}" >> "${GITHUB_OUTPUT}" + echo "- Purge failed for: ${failed:-none}" | tee -a "${GITHUB_STEP_SUMMARY}" - - name: Wait for @latest to serve v${{ steps.release.outputs.version }} + # A file whose purge failed is retried even when this runner's CDN server already serves it fresh. + - name: Check @latest serves v${{ needs.release.outputs.version }} + id: check env: - VERSION: ${{ steps.release.outputs.version }} + FAILED: ${{ steps.purge.outputs.failed }} run: | - set -euo pipefail - served_version() { - curl -sfS -D - -o /dev/null "https://cdn.jsdelivr.net/gh/${GITHUB_REPOSITORY}@latest/$1" \ - | tr -d '\r' | awk -F': ' 'tolower($1) == "x-jsd-version" { print $2 }' - } - for i in $(seq 1 30); do - sleep 10 - stale="" - for f in ${FILES}; do - v=$(served_version "${f}" || true) - [ "${v}" = "${VERSION}" ] || stale="${stale} ${f}" - echo "${f}: ${v:-unavailable}" - done - if [ -z "${stale}" ]; then - echo "@latest serves ${VERSION} for every file." | tee -a "${GITHUB_STEP_SUMMARY}" - exit 0 - fi - echo "Stale (${i}/30):${stale}" - .github/scripts/purge-jsdelivr.sh ${stale} - done - echo "::error::@latest still serves an older version after 5 minutes:${stale}" - exit 1 + stale=$(.github/scripts/stale-jsdelivr-files.sh "${VERSION}" ${FILES} | xargs) + retry=$(printf '%s\n' ${stale} ${FAILED} | sort -u | xargs) + echo "retry=${retry}" >> "${GITHUB_OUTPUT}" + echo "- Stale after the first purge: ${stale:-none}" | tee -a "${GITHUB_STEP_SUMMARY}" + + # jsDelivr caches a repo's version list for up to 10 minutes and a purge doesn't refresh it, so a + # purge before then refills with the previous release (jsdelivr/jsdelivr#18376). + - name: Wait 10 minutes, then purge those files again + if: ${{ steps.check.outputs.retry != '' }} + env: + RETRY: ${{ steps.check.outputs.retry }} + run: | + sleep 600 + .github/scripts/purge-jsdelivr.sh ${RETRY} > /dev/null - - name: Purge jsDelivr cache again - run: .github/scripts/purge-jsdelivr.sh ${FILES} + - name: Check @latest serves v${{ needs.release.outputs.version }} again + if: ${{ steps.check.outputs.retry != '' }} + run: | + stale=$(.github/scripts/stale-jsdelivr-files.sh "${VERSION}" ${FILES} | xargs) + if [ -n "${stale}" ]; then + echo "::error::@latest still serves an older version of: ${stale}. Customer sites load these files. Purging again within the hour is throttled; re-run failed jobs after an hour." + exit 1 + fi + echo "- Every @latest file serves ${VERSION}." | tee -a "${GITHUB_STEP_SUMMARY}"