diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 990be87..342454b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,7 @@ jobs: - name: Unit tests run: pnpm test - - # surface_tag.js / surface_embed_v1.js are committed CDN artifacts. - # Fail if src/ was edited without rebuilding them. - - name: Bundle is up to date - run: | - pnpm run build - git diff --exit-code surface_tag.js surface_embed_v1.js + # NOTE: the "Bundle is up to date" build-guard was removed — surface-forms + # (packages/surface-scripts) now owns the tag source and pushes the built + # surface_tag.js / surface_embed_v1.js here, so rebuilding from this repo's + # (now non-authoritative) src/ would diff against the pushed bytes and fail main. diff --git a/.github/workflows/release-and-purge.yml b/.github/workflows/release-and-purge.yml new file mode 100644 index 0000000..a09c190 --- /dev/null +++ b/.github/workflows/release-and-purge.yml @@ -0,0 +1,98 @@ +# STEP 2 of the two-step Surface Tag publish pipeline. +# +# Step 1 (surface-forms/.github/workflows/push-scripts-to-cdn.yml): builds the two bundles and pushes +# them to this repo's main in one commit (via the "Scripts Repo Deploy" GitHub App). +# Step 2 (this file): that push triggers this workflow, which cuts a release (advancing jsDelivr +# @latest) and purges the CDN so customers get the new bytes immediately. +# +# No provisioned token needed — this Action acts on its OWN repo, so the built-in GITHUB_TOKEN (granted +# contents:write below) is enough. surface-forms is now the source of truth; this repo only serves the +# built artifacts it pushes (the ci.yml "Bundle is up to date" build-guard is removed in this same PR). + +name: Release Surface Tag + purge CDN + +on: + push: + branches: [main] + # Only react to a real tag-bundle change, not docs/readme commits. + paths: + - surface_tag.js + - surface_embed_v1.js + +permissions: + contents: write + +# Never let two releases race (e.g. two quick pushes); run them one at a time. +concurrency: + group: release-surface-tag + cancel-in-progress: false + +jobs: + release-and-purge: + runs-on: ubuntu-latest + steps: + # 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 + # pushed commit, so @latest serves precisely these bytes. + - name: Cut a release for the pushed commit + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Read the latest release tag. Only a genuine "no releases yet" (HTTP 404) may fall back to + # v1.1.0 — any OTHER failure (auth, rate limit, network, 5xx) must NOT fabricate a version: + # a fabricated low tag either collides with an existing one (hard fail AFTER the bytes are + # already on main, so @latest is stuck) or, when it's below the true latest, silently fails + # to advance @latest while this job goes green. + err=$(mktemp) + if latest=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>"${err}"); then + : + elif grep -q "HTTP 404" "${err}"; then + latest="v1.1.0" + else + cat "${err}" >&2 + echo "::error::Could not read the latest release (not a 404). Refusing to fabricate a version and risk a stale @latest — re-run once the GitHub API is reachable." + exit 1 + fi + rm -f "${err}" + # Validate vMAJOR.MINOR.PATCH before the arithmetic — a malformed or pre-release latest tag + # would otherwise yield an invalid/duplicate tag and silently stop @latest from advancing. + # Each component must have NO leading zeros: a zero-padded value like 008 is not valid semver, + # and Bash would parse it as octal ("008" -> value-too-great-for-base) and abort the release. + if [[ ! "${latest}" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Latest release tag '${latest}' is not vMAJOR.MINOR.PATCH with no leading zeros; refusing to derive the next release. Fix the latest release tag." + exit 1 + fi + MAJOR="${BASH_REMATCH[1]}"; MINOR="${BASH_REMATCH[2]}"; PATCH="${BASH_REMATCH[3]}" + # Force base-10 so the increment can never be misread as octal, even if the guard above changes. + RELEASE_TAG="v${MAJOR}.${MINOR}.$((10#${PATCH} + 1))" + echo "Latest release ${latest}; cutting ${RELEASE_TAG} at ${GITHUB_SHA}" + gh api -X POST "repos/${GITHUB_REPOSITORY}/releases" \ + -f tag_name="${RELEASE_TAG}" \ + -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." + + # jsDelivr caches aggressively; purge so @latest / @main serve the new bytes immediately instead of + # up to 7 days later. A swallowed purge failure would show a green release while customers keep stale + # bytes, so fail loudly. + - name: Purge jsDelivr cache + run: | + set -euo pipefail + purge_failed=0 + for f in surface_tag.js surface_tag.min.js surface_embed_v1.js surface_embed_v1.min.js; do + for ref in latest main; do + url="https://purge.jsdelivr.net/gh/${GITHUB_REPOSITORY}@${ref}/${f}" + echo "Purging ${url}" + if ! curl -sfS "${url}"; then + echo "::warning::purge failed for ${url}" + purge_failed=1 + fi + done + done + if [ "${purge_failed}" -ne 0 ]; then + echo "::error::One or more jsDelivr purges failed; @latest/@main may serve stale bytes. Re-run the purge." + exit 1 + fi