Skip to content

Commit 0105135

Browse files
[FSSDK-12881] NPM to GPR backfill + release workflow update (#1168)
1 parent 7d8e5ba commit 0105135

3 files changed

Lines changed: 265 additions & 46 deletions

File tree

.github/workflows/ghr_backfill.yml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Backfill GitHub Package Registry
2+
3+
# Manually triggered. Copies versions already published on npm into the GitHub
4+
# Package Registry (GHR) so GHR mirrors npm. Re-publishing the npm tarball
5+
# guarantees the GHR copy is byte-identical to what npm consumers received
6+
# (no rebuild).
7+
#
8+
# dist-tags: scripts/publish.sh computes the tag for each version from the
9+
# version string against GHR's current latest (latest only when strictly newer,
10+
# otherwise v<major>-last-published; beta/alpha/rc for pre-releases), so latest
11+
# never moves backwards while backfilling historical versions.
12+
#
13+
# Idempotent: scripts/publish.sh skips any version already present on GHR, so
14+
# this can be re-run safely.
15+
16+
on:
17+
workflow_dispatch:
18+
inputs:
19+
version:
20+
description: "Single version to backfill (e.g. 5.3.4). Leave blank to process all published npm versions (versions already on GHR are skipped)."
21+
required: false
22+
default: ""
23+
dry_run:
24+
description: "Dry run: report what would be published to GHR without publishing."
25+
type: boolean
26+
required: false
27+
default: false
28+
29+
jobs:
30+
backfill:
31+
name: Backfill npm versions to GHR
32+
runs-on: ubuntu-latest
33+
permissions:
34+
contents: read
35+
packages: write
36+
steps:
37+
- name: Checkout branch
38+
uses: actions/checkout@v4
39+
with:
40+
persist-credentials: false
41+
42+
- name: Set up Node
43+
uses: actions/setup-node@v4
44+
with:
45+
node-version: 18
46+
47+
- name: Configure GitHub Package Registry auth
48+
run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc
49+
env:
50+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51+
52+
- name: Backfill
53+
env:
54+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55+
DRY_RUN: ${{ inputs.dry_run }}
56+
INPUT_VERSION: ${{ inputs.version }}
57+
run: |
58+
set -euo pipefail
59+
pkg="@optimizely/optimizely-sdk"
60+
npm_registry="https://registry.npmjs.org"
61+
gpr_registry="https://npm.pkg.github.com"
62+
63+
if [[ -n "$INPUT_VERSION" ]]; then
64+
versions="$INPUT_VERSION"
65+
else
66+
versions=$(npm view "$pkg" versions --json --registry "$npm_registry" | jq -r '.[]')
67+
fi
68+
69+
for v in $versions; do
70+
echo "::group::${pkg}@${v}"
71+
# npm pack writes the tarball filename to stdout and notices/errors to
72+
# stderr; keep stderr visible so pack failures (auth/404/network) show
73+
# in the logs. pipefail (set above) makes a failed pack abort the job.
74+
tarball=$(npm pack "${pkg}@${v}" --registry "$npm_registry" | tail -n1)
75+
scripts/publish.sh "$gpr_registry" "$tarball"
76+
rm -f "$tarball"
77+
echo "::endgroup::"
78+
done

.github/workflows/release.yml

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Publish SDK to NPM
1+
name: Publish SDK to NPM and GitHub Package Registry
22

33
on:
44
release:
@@ -7,71 +7,68 @@ on:
77

88
jobs:
99
publish:
10-
name: Publish to NPM
10+
name: Publish to NPM and GitHub Package Registry
1111
runs-on: ubuntu-latest
1212
if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.draft }}
13+
permissions:
14+
contents: read
15+
packages: write
1316
steps:
1417
- name: Checkout branch
1518
uses: actions/checkout@v4
19+
with:
20+
persist-credentials: false
1621

1722
- name: Setup Node
1823
uses: actions/setup-node@v3
1924
with:
2025
node-version: 18
2126
registry-url: "https://registry.npmjs.org/"
2227
always-auth: "true"
23-
env:
24-
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
2528

29+
# Add auth for the GitHub Package Registry publish. npm expands
30+
# ${NODE_AUTH_TOKEN} at runtime per-step, so each publish step uses the
31+
# right token for its registry. We do NOT route the @optimizely scope to
32+
# GHR, so `npm ci` still resolves dependencies from npm; publish.sh passes
33+
# --registry explicitly to target GHR.
34+
- name: Configure GitHub Package Registry auth
35+
run: echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> ~/.npmrc
36+
37+
# Deps only. --ignore-scripts stops `prepare` from building here; we build
38+
# exactly once in the pack step below.
2639
- name: Install dependencies
27-
run: npm install
40+
run: npm ci --ignore-scripts
2841

29-
- id: latest-release
30-
name: Export latest release git tag
42+
# Test, build, and pack a single tarball. Both registries publish this
43+
# same artifact, so npm and GHR receive byte-identical bytes and the test
44+
# suite runs only once. --ignore-scripts on pack avoids a rebuild.
45+
- id: pack
46+
name: Test, build, and pack
3147
run: |
32-
echo "latest-release-tag=$(curl -qsSL \
33-
-H "Accept: application/vnd.github+json" \
34-
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
35-
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/latest" \
36-
| jq -r .tag_name)" >> $GITHUB_OUTPUT
37-
38-
- id: npm-tag
39-
name: Determine NPM tag
40-
env:
41-
GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }}
42-
run: |
43-
VERSION=$(jq -r '.version' package.json)
44-
LATEST_RELEASE_TAG="${{ steps.latest-release.outputs['latest-release-tag']}}"
45-
46-
if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then
47-
RELEASE_TAG=${GITHUB_REF#refs/tags/}
48-
else
49-
RELEASE_TAG=$GITHUB_RELEASE_TAG
50-
fi
51-
52-
if [[ $RELEASE_TAG == $LATEST_RELEASE_TAG ]]; then
53-
echo "npm-tag=latest" >> "$GITHUB_OUTPUT"
54-
elif [[ "$VERSION" == *"-beta"* ]]; then
55-
echo "npm-tag=beta" >> "$GITHUB_OUTPUT"
56-
elif [[ "$VERSION" == *"-alpha"* ]]; then
57-
echo "npm-tag=alpha" >> "$GITHUB_OUTPUT"
58-
elif [[ "$VERSION" == *"-rc"* ]]; then
59-
echo "npm-tag=rc" >> "$GITHUB_OUTPUT"
60-
else
61-
echo "npm-tag=v$(echo $VERSION | awk -F. '{print $1}')-latest" >> "$GITHUB_OUTPUT"
62-
fi
48+
npm test
49+
npm run build
50+
tarball=$(npm pack --ignore-scripts | tail -n1)
51+
echo "tarball=$tarball" >> "$GITHUB_OUTPUT"
6352
53+
# publish.sh derives name/version from the tarball and computes the
54+
# dist-tag itself: `latest` only when strictly newer than the registry's
55+
# current latest, otherwise v<major>-last-published; beta/alpha/rc for
56+
# pre-releases. npm publishes first; if it fails the job stops before GHR,
57+
# keeping the registries in sync.
6458
- id: release
65-
name: Test, build and publish to npm
59+
name: Publish to NPM
6660
env:
67-
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
68-
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
6961
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
70-
run: |
71-
if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then
72-
DRY_RUN="--dry-run"
73-
fi
74-
npm publish --tag=${{ steps.npm-tag.outputs['npm-tag'] }} $DRY_RUN
62+
TARBALL: ${{ steps.pack.outputs.tarball }}
63+
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
64+
run: scripts/publish.sh "https://registry.npmjs.org" "$TARBALL"
65+
66+
- name: Publish to GitHub Package Registry
67+
env:
68+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
69+
TARBALL: ${{ steps.pack.outputs.tarball }}
70+
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
71+
run: scripts/publish.sh "https://npm.pkg.github.com" "$TARBALL"
7572

7673
# - name: Report results to Jellyfish
7774
# uses: optimizely/jellyfish-deployment-reporter-action@main

scripts/publish.sh

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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

Comments
 (0)