Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/ghr_backfill.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Backfill GitHub Package Registry

# Manually triggered. Copies versions already published on npm into the GitHub
# Package Registry (GHR) so GHR mirrors npm. Re-publishing the npm tarball
# guarantees the GHR copy is byte-identical to what npm consumers received
# (no rebuild).
#
# dist-tags: scripts/publish.sh computes the tag for each version from the
# version string against GHR's current latest (latest only when strictly newer,
# otherwise v<major>-last-published; beta/alpha/rc for pre-releases), so latest
# never moves backwards while backfilling historical versions.
#
# 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. 5.3.4). 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
Comment thread
arnica-github-connector[bot] marked this conversation as resolved.
with:
persist-credentials: false

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 18

- name: Configure GitHub Package Registry auth
run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Backfill
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
pkg="@optimizely/optimizely-sdk"
npm_registry="https://registry.npmjs.org"
gpr_registry="https://npm.pkg.github.com"

if [[ -n "$INPUT_VERSION" ]]; then
versions="$INPUT_VERSION"
else
versions=$(npm view "$pkg" versions --json --registry "$npm_registry" | 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 "$npm_registry" | tail -n1)
scripts/publish.sh "$gpr_registry" "$tarball"
rm -f "$tarball"
echo "::endgroup::"
done
89 changes: 43 additions & 46 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Publish SDK to NPM
name: Publish SDK to NPM and GitHub Package Registry

on:
release:
Expand All @@ -7,71 +7,68 @@ on:

jobs:
publish:
name: Publish to NPM
name: Publish to NPM and GitHub Package Registry
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.draft }}
permissions:
contents: read
packages: write
steps:
- name: Checkout branch
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
registry-url: "https://registry.npmjs.org/"
always-auth: "true"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

# Add auth for the GitHub Package Registry publish. npm expands
# ${NODE_AUTH_TOKEN} at runtime per-step, so each publish step uses the
# right token for its registry. We do NOT route the @optimizely scope to
# GHR, so `npm ci` still resolves dependencies from npm; publish.sh passes
# --registry explicitly to target GHR.
- name: Configure GitHub Package Registry auth
run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc

# Deps only. --ignore-scripts stops `prepare` from building here; we build
# exactly once in the pack step below.
- name: Install dependencies
run: npm install
run: npm ci --ignore-scripts

- id: latest-release
name: Export latest release git tag
# Test, build, and pack a single tarball. Both registries publish this
# same artifact, so npm and GHR receive byte-identical bytes and the test
# suite runs only once. --ignore-scripts on pack avoids a rebuild.
- id: pack
name: Test, build, and pack
run: |
echo "latest-release-tag=$(curl -qsSL \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/latest" \
| jq -r .tag_name)" >> $GITHUB_OUTPUT

- id: npm-tag
name: Determine NPM tag
env:
GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
VERSION=$(jq -r '.version' package.json)
LATEST_RELEASE_TAG="${{ steps.latest-release.outputs['latest-release-tag']}}"

if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then
RELEASE_TAG=${GITHUB_REF#refs/tags/}
else
RELEASE_TAG=$GITHUB_RELEASE_TAG
fi

if [[ $RELEASE_TAG == $LATEST_RELEASE_TAG ]]; then
echo "npm-tag=latest" >> "$GITHUB_OUTPUT"
elif [[ "$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=v$(echo $VERSION | awk -F. '{print $1}')-latest" >> "$GITHUB_OUTPUT"
fi
npm test
npm run build
tarball=$(npm pack --ignore-scripts | tail -n1)
echo "tarball=$tarball" >> "$GITHUB_OUTPUT"

# publish.sh derives name/version from the tarball and computes the
# dist-tag itself: `latest` only when strictly newer than the registry's
# current latest, otherwise v<major>-last-published; beta/alpha/rc for
# pre-releases. npm publishes first; if it fails the job stops before GHR,
# keeping the registries in sync.
- id: release
name: Test, build and publish to npm
name: Publish to NPM
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
run: |
if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then
DRY_RUN="--dry-run"
fi
npm publish --tag=${{ steps.npm-tag.outputs['npm-tag'] }} $DRY_RUN
TARBALL: ${{ steps.pack.outputs.tarball }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
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 }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
run: scripts/publish.sh "https://npm.pkg.github.com" "$TARBALL"

# - name: Report results to Jellyfish
# uses: optimizely/jellyfish-deployment-reporter-action@main
Expand Down
144 changes: 144 additions & 0 deletions scripts/publish.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# Registry-agnostic, idempotent publish for @optimizely/optimizely-sdk.
#
# Usage:
# scripts/publish.sh <registry-url> [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 (`//<host>/:_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`, so `latest` never moves
# backwards onto an older release — e.g. 5.x shipped after 6.x, or a
# 6.4.1 patch shipped while latest is 6.5.0. Otherwise it is tagged
# `v<major>-last-published`: a per-major pointer to the most recently
# published version on that major line. It is named for recency (not
# "latest") on purpose — an out-of-order backport can leave it below the
# highest version in the major. Consumers install a line via
# @<pkg>@v<major>-last-published.
set -euo pipefail

dry_run="${DRY_RUN:-false}"

registry="${1:?usage: publish.sh <registry-url> [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%%.*}
tag="v${major}-last-published"
echo "Current latest is ${current_latest}; ${version} is not newer, tagging as ${tag} (latest preserved)."
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
Loading