|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# Registry-agnostic, idempotent publish for @optimizely/optimizely-sdk. |
| 4 | +# |
| 5 | +# Usage: |
| 6 | +# scripts/publish.sh <registry-url> [tarball] |
| 7 | +# |
| 8 | +# Args: |
| 9 | +# registry-url Target registry, e.g. https://registry.npmjs.org |
| 10 | +# or https://npm.pkg.github.com |
| 11 | +# tarball Optional. When given, publishes that packed tarball instead |
| 12 | +# of the current working directory (used by the GHR backfill). |
| 13 | +# |
| 14 | +# Env: |
| 15 | +# NODE_AUTH_TOKEN Auth token for the target registry. Used both to read the |
| 16 | +# registry (existence/dist-tag lookup) and, via the caller's |
| 17 | +# .npmrc entry (`//<host>/:_authToken=${NODE_AUTH_TOKEN}`, |
| 18 | +# which setup-node writes), to publish. This script passes |
| 19 | +# --registry explicitly, so no scope-to-registry routing is |
| 20 | +# required (and the GHR job intentionally does NOT route |
| 21 | +# @optimizely to GHR, so that `npm ci` still installs |
| 22 | +# dependencies from npm). |
| 23 | +# DRY_RUN When "true", report what would happen (publish vs. skip) |
| 24 | +# without actually publishing. |
| 25 | +# |
| 26 | +# Behavior: |
| 27 | +# - Reads the target registry's packument once (a single HTTP GET) and: |
| 28 | +# * 200 -> package exists; skip (exit 0) if this exact version is already |
| 29 | +# present, so re-running a release or the backfill is a safe no-op. |
| 30 | +# * 404 -> package/version absent; proceed to publish. |
| 31 | +# * any other status or a network failure -> abort (exit 1) rather than |
| 32 | +# guess. A flaky lookup must never be misread as "not published" and |
| 33 | +# trigger a publish. |
| 34 | +# - Computes the dist-tag from the version: |
| 35 | +# * pre-releases (beta/alpha/rc) get their own tag, never `latest`. |
| 36 | +# * a stable release gets `latest` ONLY when it is strictly greater (by |
| 37 | +# semver) than the registry's current `latest`, so `latest` never moves |
| 38 | +# backwards onto an older release — e.g. 5.x shipped after 6.x, or a |
| 39 | +# 6.4.1 patch shipped while latest is 6.5.0. Otherwise it is tagged |
| 40 | +# `v<major>-last-published`: a per-major pointer to the most recently |
| 41 | +# published version on that major line. It is named for recency (not |
| 42 | +# "latest") on purpose — an out-of-order backport can leave it below the |
| 43 | +# highest version in the major. Consumers install a line via |
| 44 | +# @<pkg>@v<major>-last-published. |
| 45 | +set -euo pipefail |
| 46 | + |
| 47 | +dry_run="${DRY_RUN:-false}" |
| 48 | + |
| 49 | +registry="${1:?usage: publish.sh <registry-url> [tarball]}" |
| 50 | +tarball="${2:-}" |
| 51 | + |
| 52 | +if [[ -n "$tarball" ]]; then |
| 53 | + # Derive name/version from the tarball's own package.json so the guard matches |
| 54 | + # exactly what we're about to publish (backfill of historical versions). |
| 55 | + meta=$(tar -xzO -f "$tarball" package/package.json) |
| 56 | + pkg=$(printf '%s' "$meta" | jq -r '.name') |
| 57 | + version=$(printf '%s' "$meta" | jq -r '.version') |
| 58 | +else |
| 59 | + pkg=$(jq -r '.name' package.json) |
| 60 | + version=$(jq -r '.version' package.json) |
| 61 | +fi |
| 62 | + |
| 63 | +# Returns 0 if $1 is strictly greater than $2 (numeric major.minor.patch). Only |
| 64 | +# called for stable versions, so no pre-release precedence handling is needed. |
| 65 | +version_gt() { |
| 66 | + local -a a b |
| 67 | + local i x y |
| 68 | + IFS=. read -ra a <<< "$1" |
| 69 | + IFS=. read -ra b <<< "$2" |
| 70 | + for i in 0 1 2; do |
| 71 | + x=${a[i]:-0} |
| 72 | + y=${b[i]:-0} |
| 73 | + (( 10#$x > 10#$y )) && return 0 |
| 74 | + (( 10#$x < 10#$y )) && return 1 |
| 75 | + done |
| 76 | + return 1 |
| 77 | +} |
| 78 | + |
| 79 | +# --- Existence / current-latest lookup (single packument GET) ---------------- |
| 80 | +# npm scoped names are URL-encoded with the slash as %2f: @scope/name. |
| 81 | +pkg_encoded="${pkg//\//%2f}" |
| 82 | +packument_url="${registry%/}/${pkg_encoded}" |
| 83 | + |
| 84 | +body_file=$(mktemp) |
| 85 | +trap 'rm -f "$body_file"' EXIT |
| 86 | + |
| 87 | +# curl -sS (no --fail) returns 0 for any HTTP response, non-zero only on |
| 88 | +# network/protocol errors — so we can cleanly separate "server answered" from |
| 89 | +# "could not reach server". |
| 90 | +if ! http_code=$(curl -sS -m 30 -o "$body_file" -w '%{http_code}' \ |
| 91 | + -H "Authorization: Bearer ${NODE_AUTH_TOKEN:-}" "$packument_url"); then |
| 92 | + echo "ERROR: request to ${packument_url} failed (network/timeout); refusing to publish on an ambiguous result." >&2 |
| 93 | + exit 1 |
| 94 | +fi |
| 95 | + |
| 96 | +current_latest="" |
| 97 | +case "$http_code" in |
| 98 | + 200) |
| 99 | + if jq -e --arg v "$version" '.versions[$v] != null' "$body_file" >/dev/null 2>&1; then |
| 100 | + echo "Version ${pkg}@${version} already on ${registry}, skipping." |
| 101 | + exit 0 |
| 102 | + fi |
| 103 | + current_latest=$(jq -r '.["dist-tags"].latest // empty' "$body_file") |
| 104 | + ;; |
| 105 | + 404) |
| 106 | + # Package (or this version) not published yet; nothing to compare against. |
| 107 | + current_latest="" |
| 108 | + ;; |
| 109 | + *) |
| 110 | + echo "ERROR: unexpected HTTP ${http_code} querying ${packument_url}; refusing to publish on an ambiguous result." >&2 |
| 111 | + exit 1 |
| 112 | + ;; |
| 113 | +esac |
| 114 | + |
| 115 | +# --- dist-tag selection ------------------------------------------------------ |
| 116 | +case "$version" in |
| 117 | + *-beta*) tag=beta ;; |
| 118 | + *-alpha*) tag=alpha ;; |
| 119 | + *-rc*) tag=rc ;; |
| 120 | + *) |
| 121 | + # Stable release: `latest` only if strictly greater than the current latest. |
| 122 | + if [[ -z "$current_latest" ]] || version_gt "$version" "$current_latest"; then |
| 123 | + tag=latest |
| 124 | + else |
| 125 | + major=${version%%.*} |
| 126 | + tag="v${major}-last-published" |
| 127 | + echo "Current latest is ${current_latest}; ${version} is not newer, tagging as ${tag} (latest preserved)." |
| 128 | + fi |
| 129 | + ;; |
| 130 | +esac |
| 131 | + |
| 132 | +if [[ "$dry_run" == "true" ]]; then |
| 133 | + echo "[dry-run] would publish ${pkg}@${version} (tag: ${tag}) to ${registry}" |
| 134 | + exit 0 |
| 135 | +fi |
| 136 | + |
| 137 | +echo "Publishing ${pkg}@${version} (tag: ${tag}) to ${registry}" |
| 138 | +if [[ -n "$tarball" ]]; then |
| 139 | + # The tarball is a prebuilt artifact; skip lifecycle scripts (prepublishOnly |
| 140 | + # = test + build) that would otherwise run from the current package.json. |
| 141 | + npm publish "$tarball" --registry "$registry" --tag "$tag" --ignore-scripts |
| 142 | +else |
| 143 | + npm publish --registry "$registry" --tag "$tag" |
| 144 | +fi |
0 commit comments