diff --git a/.github/workflows/ghr_backfill.yml b/.github/workflows/ghr_backfill.yml new file mode 100644 index 0000000..d6d6db1 --- /dev/null +++ b/.github/workflows/ghr_backfill.yml @@ -0,0 +1,68 @@ +name: Backfill GitHub Package Registry + +# Manually triggered. Copies versions already published on npm into the GitHub +# Package Registry so GHR mirrors npm. Re-publishing the npm tarball guarantees +# the GHR copy is byte-identical to what npm consumers received (no rebuild). +# +# Idempotent: scripts/publish.sh skips any version already present on GHR, so +# this can be re-run safely. + +on: + workflow_dispatch: + inputs: + version: + description: "Single version to backfill (e.g. 4.0.0). Leave blank to process all published npm versions (versions already on GHR are skipped)." + required: false + default: "" + dry_run: + description: "Dry run: report what would be published to GHR without publishing." + type: boolean + required: false + default: false + +jobs: + backfill: + name: Backfill npm versions to GHR + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout branch + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Configure GHR auth + run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc + + - name: Backfill + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + pkg="@optimizely/react-sdk" + + if [[ -n "$INPUT_VERSION" ]]; then + versions="$INPUT_VERSION" + else + versions=$(npm view "$pkg" versions --json --registry https://registry.npmjs.org | jq -r '.[]') + fi + + for v in $versions; do + echo "::group::${pkg}@${v}" + # npm pack writes the tarball filename to stdout and notices/errors to + # stderr; keep stderr visible so pack failures (auth/404/network) show + # in the logs. pipefail (set above) makes a failed pack abort the job. + tarball=$(npm pack "${pkg}@${v}" --registry https://registry.npmjs.org | tail -n1) + scripts/publish.sh "https://npm.pkg.github.com" "$tarball" + rm -f "$tarball" + echo "::endgroup::" + done diff --git a/.github/workflows/react_release.yml b/.github/workflows/react_release.yml index 2b02c46..db5ea57 100644 --- a/.github/workflows/react_release.yml +++ b/.github/workflows/react_release.yml @@ -1,4 +1,4 @@ -name: Publish React SDK to NPM +name: Publish React SDK on: release: @@ -6,11 +6,16 @@ on: jobs: publish: - name: Publish to NPM + name: Publish to NPM and GitHub Package Registry runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout branch uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Node uses: actions/setup-node@v4 @@ -19,27 +24,29 @@ jobs: registry-url: "https://registry.npmjs.org/" always-auth: "true" cache: 'npm' - env: - NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_REACT_TO_NPM_FROM_GITHUB }} + + - name: Configure GHR auth + run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - - id: npm-tag - name: Determine NPM tag + - name: Test, build, and pack + id: pack run: | - version=$(jq -r '.version' package.json) - if [[ "$version" == *"-beta"* ]]; then - echo "npm-tag=beta" >> "$GITHUB_OUTPUT" - elif [[ "$version" == *"-alpha"* ]]; then - echo "npm-tag=alpha" >> "$GITHUB_OUTPUT" - elif [[ "$version" == *"-rc"* ]]; then - echo "npm-tag=rc" >> "$GITHUB_OUTPUT" - else - echo "npm-tag=latest" >> "$GITHUB_OUTPUT" - fi - - - name: Test, build, then publish + npm run test + npm run build + tarball=$(npm pack --ignore-scripts | tail -n1) + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + + - name: Publish to NPM env: NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_REACT_TO_NPM_FROM_GITHUB }} - run: npm publish --tag ${{ steps.npm-tag.outputs['npm-tag'] }} + TARBALL: ${{ steps.pack.outputs.tarball }} + run: scripts/publish.sh "https://registry.npmjs.org" "$TARBALL" + + - name: Publish to GitHub Package Registry + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARBALL: ${{ steps.pack.outputs.tarball }} + run: scripts/publish.sh "https://npm.pkg.github.com" "$TARBALL" diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..79eeb64 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# Registry-agnostic, idempotent publish for @optimizely/react-sdk. +# +# Usage: +# scripts/publish.sh [tarball] +# +# Args: +# registry-url Target registry, e.g. https://registry.npmjs.org +# or https://npm.pkg.github.com +# tarball Optional. When given, publishes that packed tarball instead +# of the current working directory (used by the GHR backfill). +# +# Env: +# NODE_AUTH_TOKEN Auth token for the target registry. Used both to read the +# registry (existence/dist-tag lookup) and, via the caller's +# .npmrc entry (`///:_authToken=${NODE_AUTH_TOKEN}`, +# which setup-node writes), to publish. This script passes +# --registry explicitly, so no scope-to-registry routing is +# required (and the GHR job intentionally does NOT route +# @optimizely to GHR, so that `npm ci` still installs +# dependencies from npm). +# DRY_RUN When "true", report what would happen (publish vs. skip) +# without actually publishing. +# +# Behavior: +# - Reads the target registry's packument once (a single HTTP GET) and: +# * 200 -> package exists; skip (exit 0) if this exact version is already +# present, so re-running a release or the backfill is a safe no-op. +# * 404 -> package/version absent; proceed to publish. +# * any other status or a network failure -> abort (exit 1) rather than +# guess. A flaky lookup must never be misread as "not published" and +# trigger a publish. +# - Computes the dist-tag from the version: +# * pre-releases (beta/alpha/rc) get their own tag, never `latest`. +# * a stable release gets `latest` ONLY when it is strictly greater (by +# semver) than the registry's current `latest`. Otherwise it is tagged +# `v.-latest` (its own release line), so `latest` never +# moves backwards onto an older release — this covers both an older +# major (5.x shipped after 6.x) and an older minor of the current major +# (6.4.1 shipped while latest is 6.5.0). +set -euo pipefail + +dry_run="${DRY_RUN:-false}" + +registry="${1:?usage: publish.sh [tarball]}" +tarball="${2:-}" + +if [[ -n "$tarball" ]]; then + # Derive name/version from the tarball's own package.json so the guard matches + # exactly what we're about to publish (backfill of historical versions). + meta=$(tar -xzO -f "$tarball" package/package.json) + pkg=$(printf '%s' "$meta" | jq -r '.name') + version=$(printf '%s' "$meta" | jq -r '.version') +else + pkg=$(jq -r '.name' package.json) + version=$(jq -r '.version' package.json) +fi + +# Returns 0 if $1 is strictly greater than $2 (numeric major.minor.patch). Only +# called for stable versions, so no pre-release precedence handling is needed. +version_gt() { + local -a a b + local i x y + IFS=. read -ra a <<< "$1" + IFS=. read -ra b <<< "$2" + for i in 0 1 2; do + x=${a[i]:-0} + y=${b[i]:-0} + (( 10#$x > 10#$y )) && return 0 + (( 10#$x < 10#$y )) && return 1 + done + return 1 +} + +# --- Existence / current-latest lookup (single packument GET) ---------------- +# npm scoped names are URL-encoded with the slash as %2f: @scope/name. +pkg_encoded="${pkg//\//%2f}" +packument_url="${registry%/}/${pkg_encoded}" + +body_file=$(mktemp) +trap 'rm -f "$body_file"' EXIT + +# curl -sS (no --fail) returns 0 for any HTTP response, non-zero only on +# network/protocol errors — so we can cleanly separate "server answered" from +# "could not reach server". +if ! http_code=$(curl -sS -m 30 -o "$body_file" -w '%{http_code}' \ + -H "Authorization: Bearer ${NODE_AUTH_TOKEN:-}" "$packument_url"); then + echo "ERROR: request to ${packument_url} failed (network/timeout); refusing to publish on an ambiguous result." >&2 + exit 1 +fi + +current_latest="" +case "$http_code" in + 200) + if jq -e --arg v "$version" '.versions[$v] != null' "$body_file" >/dev/null 2>&1; then + echo "Version ${pkg}@${version} already on ${registry}, skipping." + exit 0 + fi + current_latest=$(jq -r '.["dist-tags"].latest // empty' "$body_file") + ;; + 404) + # Package (or this version) not published yet; nothing to compare against. + current_latest="" + ;; + *) + echo "ERROR: unexpected HTTP ${http_code} querying ${packument_url}; refusing to publish on an ambiguous result." >&2 + exit 1 + ;; +esac + +# --- dist-tag selection ------------------------------------------------------ +case "$version" in + *-beta*) tag=beta ;; + *-alpha*) tag=alpha ;; + *-rc*) tag=rc ;; + *) + # Stable release: `latest` only if strictly greater than the current latest. + if [[ -z "$current_latest" ]] || version_gt "$version" "$current_latest"; then + tag=latest + else + major=${version%%.*} + rest=${version#*.} + minor=${rest%%.*} + tag="v${major}.${minor}-latest" + echo "Current latest is ${current_latest}; ${version} is not newer, tagging as ${tag} to preserve latest." + fi + ;; +esac + +if [[ "$dry_run" == "true" ]]; then + echo "[dry-run] would publish ${pkg}@${version} (tag: ${tag}) to ${registry}" + exit 0 +fi + +echo "Publishing ${pkg}@${version} (tag: ${tag}) to ${registry}" +if [[ -n "$tarball" ]]; then + # The tarball is a prebuilt artifact; skip lifecycle scripts (prepublishOnly + # = test + build) that would otherwise run from the current package.json. + npm publish "$tarball" --registry "$registry" --tag "$tag" --ignore-scripts +else + npm publish --registry "$registry" --tag "$tag" +fi