From ba37dcd0c83252390b9f3b5c88d9c74b23cccc7f Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:37:39 +0600 Subject: [PATCH 1/4] [FSSDK-12881] impl --- .github/workflows/ghr_backfill.yml | 89 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 51 ++++++++++++----- scripts/publish.sh | 60 ++++++++++++++++++++ 3 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/ghr_backfill.yml create mode 100755 scripts/publish.sh diff --git a/.github/workflows/ghr_backfill.yml b/.github/workflows/ghr_backfill.yml new file mode 100644 index 000000000..54be52061 --- /dev/null +++ b/.github/workflows/ghr_backfill.yml @@ -0,0 +1,89 @@ +name: Backfill GitHub Package Registry + +# Manually triggered. Copies versions already published on npm into the GitHub +# Package Registry (GPR) so GPR mirrors npm. Re-publishing the npm tarball +# guarantees the GPR copy is byte-identical to what npm consumers received +# (no rebuild). +# +# dist-tags: each backfilled version is published under the dist-tag npm +# currently assigns it (latest / v{major}-latest / beta / alpha / rc), so GPR +# mirrors npm's pointers. Versions npm no longer tags are published under an +# "imported" tag, which never disturbs `latest`. +# +# Idempotent: scripts/publish.sh skips any version already present on GPR, 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 backfill all versions." + required: false + default: "" + dry_run: + description: "Dry run: report what would be published to GPR without publishing." + type: boolean + required: false + default: false + +jobs: + backfill: + name: Backfill npm versions to GPR + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout branch + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 18 + registry-url: "https://registry.npmjs.org/" + always-auth: "true" + + # Add GPR auth for publishing. npm expands ${NODE_AUTH_TOKEN} at runtime, + # and scripts/publish.sh passes --registry explicitly, so the default + # registry stays on npm (backfill reads tarballs from npm, writes to GPR). + - 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: ${{ github.event.inputs.dry_run }} + INPUT_VERSION: ${{ github.event.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 + + # Read npm's current dist-tags once and build a version -> tag map, so + # each backfilled version lands under the same tag it has on npm. + # `npm dist-tag ls` prints "tag: version" lines. Anything not currently + # tagged falls back to "imported" (a neutral tag that never moves latest). + dist_tags_json=$(npm dist-tag ls "$pkg" --registry "$npm_registry" \ + | jq -R -s 'split("\n") | map(select(length > 0) | split(": ")) | map({(.[1]): .[0]}) | add // {}') + + for v in $versions; do + echo "::group::${pkg}@${v}" + tag=$(printf '%s' "$dist_tags_json" | jq -r --arg v "$v" '.[$v] // "imported"') + # 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" "$tag" + rm -f "$tarball" + echo "::endgroup::" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3e710a44..2d1261bfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Publish SDK to NPM +name: Publish SDK to NPM and GitHub Package Registry on: release: @@ -7,9 +7,12 @@ 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 @@ -20,11 +23,19 @@ jobs: 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 + # GPR, so `npm ci` still resolves dependencies from npm; publish.sh passes + # --registry explicitly to target GPR. + - 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 @@ -42,7 +53,7 @@ jobs: 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 @@ -61,17 +72,29 @@ jobs: echo "npm-tag=v$(echo $VERSION | awk -F. '{print $1}')-latest" >> "$GITHUB_OUTPUT" fi + # Test, build, and pack a single tarball. Both registries publish this + # same artifact, so npm and GPR 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: | + npm test + npm run build + tarball=$(npm pack --ignore-scripts | tail -n1) + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + - 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 + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }} + run: scripts/publish.sh "https://registry.npmjs.org" "${{ steps.pack.outputs.tarball }}" "${{ steps.npm-tag.outputs['npm-tag'] }}" + + - name: Publish to GitHub Package Registry + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }} + run: scripts/publish.sh "https://npm.pkg.github.com" "${{ steps.pack.outputs.tarball }}" "${{ steps.npm-tag.outputs['npm-tag'] }}" # - name: Report results to Jellyfish # uses: optimizely/jellyfish-deployment-reporter-action@main diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 000000000..57c34626c --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Registry-agnostic, idempotent publish for @optimizely/optimizely-sdk. +# +# Usage: +# scripts/publish.sh +# +# Args: +# registry-url Target registry, e.g. https://registry.npmjs.org +# or https://npm.pkg.github.com +# tarball Prebuilt package tarball (output of `npm pack`) to publish. +# The release flow packs it once and publishes the same bytes to +# both registries; the backfill packs each version from npm. +# dist-tag npm dist-tag to publish under. The caller decides the tag; this +# script is deliberately tag-agnostic so the JS SDK keeps its +# existing tag logic in the workflow (latest / v{major}-latest / +# beta / alpha / rc). +# +# Env: +# NODE_AUTH_TOKEN Auth token for the target registry. An .npmrc entry must +# reference it for 's host, e.g. +# `///:_authToken=${NODE_AUTH_TOKEN}` (setup-node writes +# this for npm; the workflow adds one for GitHub Package +# Registry). We pass --registry explicitly, so no +# scope-to-registry routing is needed (and the @optimizely +# scope is intentionally NOT routed to GPR, so `npm ci` still +# installs dependencies from npm). +# DRY_RUN When "true", report the action (publish vs. skip) without +# actually publishing. +# +# Behavior: +# - Skips (exit 0) if the version already exists on the target registry, so +# re-running a release or the backfill is always a safe no-op. +# - Publishes the prebuilt tarball with --ignore-scripts so lifecycle scripts +# (prepublishOnly = test, prepare = build) don't re-run from package.json. +set -euo pipefail + +registry="${1:?usage: publish.sh }" +tarball="${2:?usage: publish.sh }" +tag="${3:?usage: publish.sh }" +dry_run="${DRY_RUN:-false}" + +# Derive name/version from the tarball's own package.json so the existence guard +# matches exactly what we're about to publish. +meta=$(tar -xzO -f "$tarball" package/package.json) +pkg=$(printf '%s' "$meta" | jq -r '.name') +version=$(printf '%s' "$meta" | jq -r '.version') + +if npm view "${pkg}@${version}" version --registry "$registry" >/dev/null 2>&1; then + echo "Version ${pkg}@${version} already on ${registry}, skipping." + exit 0 +fi + +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}" +npm publish "$tarball" --registry "$registry" --tag "$tag" --ignore-scripts From eb92334234fb3ecaffed15c4816518a73ff8d557 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:01:07 +0600 Subject: [PATCH 2/4] [FSSDK-12881] feedback improvement --- .github/workflows/ghr_backfill.yml | 7 ++----- .github/workflows/release.yml | 2 ++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ghr_backfill.yml b/.github/workflows/ghr_backfill.yml index 54be52061..d66f6b353 100644 --- a/.github/workflows/ghr_backfill.yml +++ b/.github/workflows/ghr_backfill.yml @@ -36,17 +36,14 @@ jobs: 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 - registry-url: "https://registry.npmjs.org/" - always-auth: "true" - # Add GPR auth for publishing. npm expands ${NODE_AUTH_TOKEN} at runtime, - # and scripts/publish.sh passes --registry explicitly, so the default - # registry stays on npm (backfill reads tarballs from npm, writes to GPR). - name: Configure GitHub Package Registry auth run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d1261bfa..c26174892 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,8 @@ jobs: steps: - name: Checkout branch uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@v3 From 3bad946436bbe1f2bb885ea11da45a961cb29cd4 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:17:18 +0600 Subject: [PATCH 3/4] [FSSDK-12881] improvements --- .github/workflows/ghr_backfill.yml | 34 +++---- .github/workflows/release.yml | 52 +++------- scripts/publish.sh | 149 ++++++++++++++++++++++------- 3 files changed, 140 insertions(+), 95 deletions(-) diff --git a/.github/workflows/ghr_backfill.yml b/.github/workflows/ghr_backfill.yml index d66f6b353..739a02ca3 100644 --- a/.github/workflows/ghr_backfill.yml +++ b/.github/workflows/ghr_backfill.yml @@ -1,34 +1,34 @@ name: Backfill GitHub Package Registry # Manually triggered. Copies versions already published on npm into the GitHub -# Package Registry (GPR) so GPR mirrors npm. Re-publishing the npm tarball -# guarantees the GPR copy is byte-identical to what npm consumers received +# 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: each backfilled version is published under the dist-tag npm -# currently assigns it (latest / v{major}-latest / beta / alpha / rc), so GPR -# mirrors npm's pointers. Versions npm no longer tags are published under an -# "imported" tag, which never disturbs `latest`. +# 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-latest; 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 GPR, so +# 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 backfill all versions." + 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 GPR without publishing." + 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 GPR + name: Backfill npm versions to GHR runs-on: ubuntu-latest permissions: contents: read @@ -52,8 +52,8 @@ jobs: - name: Backfill env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DRY_RUN: ${{ github.event.inputs.dry_run }} - INPUT_VERSION: ${{ github.event.inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + INPUT_VERSION: ${{ inputs.version }} run: | set -euo pipefail pkg="@optimizely/optimizely-sdk" @@ -66,21 +66,13 @@ jobs: versions=$(npm view "$pkg" versions --json --registry "$npm_registry" | jq -r '.[]') fi - # Read npm's current dist-tags once and build a version -> tag map, so - # each backfilled version lands under the same tag it has on npm. - # `npm dist-tag ls` prints "tag: version" lines. Anything not currently - # tagged falls back to "imported" (a neutral tag that never moves latest). - dist_tags_json=$(npm dist-tag ls "$pkg" --registry "$npm_registry" \ - | jq -R -s 'split("\n") | map(select(length > 0) | split(": ")) | map({(.[1]): .[0]}) | add // {}') - for v in $versions; do echo "::group::${pkg}@${v}" - tag=$(printf '%s' "$dist_tags_json" | jq -r --arg v "$v" '.[$v] // "imported"') # 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" "$tag" + scripts/publish.sh "$gpr_registry" "$tarball" rm -f "$tarball" echo "::endgroup::" done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c26174892..911c8569d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,8 +29,8 @@ jobs: # 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 - # GPR, so `npm ci` still resolves dependencies from npm; publish.sh passes - # --registry explicitly to target GPR. + # 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 @@ -39,43 +39,8 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts - - id: latest-release - name: Export latest release git tag - 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 - # Test, build, and pack a single tarball. Both registries publish this - # same artifact, so npm and GPR receive byte-identical bytes and the test + # 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 @@ -85,18 +50,25 @@ jobs: 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-latest; 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: Publish to NPM env: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} + TARBALL: ${{ steps.pack.outputs.tarball }} DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }} - run: scripts/publish.sh "https://registry.npmjs.org" "${{ steps.pack.outputs.tarball }}" "${{ steps.npm-tag.outputs['npm-tag'] }}" + 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" "${{ steps.pack.outputs.tarball }}" "${{ steps.npm-tag.outputs['npm-tag'] }}" + run: scripts/publish.sh "https://npm.pkg.github.com" "$TARBALL" # - name: Report results to Jellyfish # uses: optimizely/jellyfish-deployment-reporter-action@main diff --git a/scripts/publish.sh b/scripts/publish.sh index 57c34626c..f9f522a60 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -3,58 +3,139 @@ # Registry-agnostic, idempotent publish for @optimizely/optimizely-sdk. # # Usage: -# scripts/publish.sh +# scripts/publish.sh [tarball] # # Args: # registry-url Target registry, e.g. https://registry.npmjs.org # or https://npm.pkg.github.com -# tarball Prebuilt package tarball (output of `npm pack`) to publish. -# The release flow packs it once and publishes the same bytes to -# both registries; the backfill packs each version from npm. -# dist-tag npm dist-tag to publish under. The caller decides the tag; this -# script is deliberately tag-agnostic so the JS SDK keeps its -# existing tag logic in the workflow (latest / v{major}-latest / -# beta / alpha / rc). +# 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. An .npmrc entry must -# reference it for 's host, e.g. -# `///:_authToken=${NODE_AUTH_TOKEN}` (setup-node writes -# this for npm; the workflow adds one for GitHub Package -# Registry). We pass --registry explicitly, so no -# scope-to-registry routing is needed (and the @optimizely -# scope is intentionally NOT routed to GPR, so `npm ci` still -# installs dependencies from npm). -# DRY_RUN When "true", report the action (publish vs. skip) without -# actually publishing. +# 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: -# - Skips (exit 0) if the version already exists on the target registry, so -# re-running a release or the backfill is always a safe no-op. -# - Publishes the prebuilt tarball with --ignore-scripts so lifecycle scripts -# (prepublishOnly = test, prepare = build) don't re-run from package.json. +# - 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` (the JS SDK's long-standing per-major release line, +# which consumers install via @@v-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. set -euo pipefail -registry="${1:?usage: publish.sh }" -tarball="${2:?usage: publish.sh }" -tag="${3:?usage: publish.sh }" dry_run="${DRY_RUN:-false}" -# Derive name/version from the tarball's own package.json so the existence guard -# matches exactly what we're about to publish. -meta=$(tar -xzO -f "$tarball" package/package.json) -pkg=$(printf '%s' "$meta" | jq -r '.name') -version=$(printf '%s' "$meta" | jq -r '.version') +registry="${1:?usage: publish.sh [tarball]}" +tarball="${2:-}" -if npm view "${pkg}@${version}" version --registry "$registry" >/dev/null 2>&1; then - echo "Version ${pkg}@${version} already on ${registry}, skipping." - exit 0 +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}-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}" -npm publish "$tarball" --registry "$registry" --tag "$tag" --ignore-scripts +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 From 543c44a90df4713e3eaeb2dda46a3273f508fa04 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:48:39 +0600 Subject: [PATCH 4/4] [FSSDK-12881] tagging improvement --- .github/workflows/ghr_backfill.yml | 4 ++-- .github/workflows/release.yml | 2 +- scripts/publish.sh | 17 ++++++++++------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ghr_backfill.yml b/.github/workflows/ghr_backfill.yml index 739a02ca3..fe0141b73 100644 --- a/.github/workflows/ghr_backfill.yml +++ b/.github/workflows/ghr_backfill.yml @@ -7,8 +7,8 @@ name: Backfill GitHub Package Registry # # 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-latest; beta/alpha/rc for pre-releases), so latest never -# moves backwards while backfilling historical versions. +# otherwise v-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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 911c8569d..29a13ace4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: # 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-latest; beta/alpha/rc for + # current latest, otherwise v-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 diff --git a/scripts/publish.sh b/scripts/publish.sh index f9f522a60..e9f874961 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -34,11 +34,14 @@ # - 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` (the JS SDK's long-standing per-major release line, -# which consumers install via @@v-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. +# 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-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 +# @@v-last-published. set -euo pipefail dry_run="${DRY_RUN:-false}" @@ -120,8 +123,8 @@ case "$version" in tag=latest else major=${version%%.*} - tag="v${major}-latest" - echo "Current latest is ${current_latest}; ${version} is not newer, tagging as ${tag} to preserve latest." + tag="v${major}-last-published" + echo "Current latest is ${current_latest}; ${version} is not newer, tagging as ${tag} (latest preserved)." fi ;; esac