From 6e665a92e24fe98d41847dff6c360af17bc3be31 Mon Sep 17 00:00:00 2001
From: Oseer Williams
<265368733+owilliams-tetrascience@users.noreply.github.com>
Date: Wed, 1 Jul 2026 14:47:42 -0400
Subject: [PATCH 1/6] chore: migrate zephyr scripts to internal zephyr library
Replace the repo's bespoke Zephyr Scale integration (hand-rolled fetch,
auth, timeouts, pagination, error handling) with the shared internal
ts-lib-zephyr-nodejs library, per SW-1779 (tech debt).
- report-zephyr-results.ts / sync-storybook-zephyr.ts now route all
Zephyr HTTP through the library's ZephyrClient. Repo-specific logic
(JUnit parsing, story parsing/write-back, testCaseId schema, cycle
find-or-create, folder mapping, reuse selection) stays local.
- Reporter now embeds each story's screenshot as a base64
into
every step of the execution (buildStepsPayload + putExecutionSteps),
replacing the previous S3-upload + URL-in-comment path.
- Screenshot transport: delete upload-test-screenshots.ts; ship
test-results/screenshots/** in the storybook-junit-results artifact;
drop the S3 upload + AWS OIDC steps from storybook-tests.yml. Remove
the now-unused @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
devDependencies.
- The library is published only to the internal registry, so it is kept
out of package.json/yarn.lock (preserving the public-registry
invariant) and installed as a leaf tarball inside the two Zephyr
workflows. Requires JFROG_ARTIFACTORY_NPM_VIRTUAL_URL and
JFROG_ARTIFACTORY_READ_NPM_AUTH secrets.
- Docs updated in AGENTS.md and CONTRIBUTING.md.
Refs: SW-1779
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/storybook-tests.yml | 26 +--
.github/workflows/zephyr-report-results.yml | 24 +++
.github/workflows/zephyr-sync-storybook.yml | 24 +++
AGENTS.md | 4 +-
CONTRIBUTING.md | 2 +
package.json | 2 -
scripts/upload-test-screenshots.ts | 214 -------------------
scripts/zephyr/report-zephyr-results.ts | 219 ++++++++------------
scripts/zephyr/sync-storybook-zephyr.ts | 159 +++++---------
yarn.lock | 32 +--
10 files changed, 198 insertions(+), 508 deletions(-)
delete mode 100644 scripts/upload-test-screenshots.ts
diff --git a/.github/workflows/storybook-tests.yml b/.github/workflows/storybook-tests.yml
index 6e744176..aafb44da 100644
--- a/.github/workflows/storybook-tests.yml
+++ b/.github/workflows/storybook-tests.yml
@@ -9,9 +9,7 @@ on:
required: false
type: string
-# Required for AWS OIDC authentication
permissions:
- id-token: write
contents: read
jobs:
@@ -63,31 +61,17 @@ jobs:
continue-on-error: true
id: test_filtered
- - name: 🔐 Configure AWS Credentials
- if: always()
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
- with:
- aws-region: ${{ secrets.TEST_ARTIFACTS_AWS_REGION }}
- role-to-assume: arn:aws:iam::${{ secrets.TEST_ARTIFACTS_AWS_ACCOUNT_ID }}:role/${{ secrets.TEST_ARTIFACTS_POLICY_NAME}}
- role-session-name: StorybookTestsSession
-
- - name: 📤 Upload Test Screenshots to S3
- if: always()
- run: npx tsx scripts/upload-test-screenshots.ts
- env:
- GITHUB_RUN_NUMBER: ${{ github.run_number }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- S3_BUCKET: ${{ secrets.TEST_ARTIFACTS_BUCKET_NAME }}
- AWS_REGION: ${{ secrets.TEST_ARTIFACTS_AWS_REGION }}
-
- - name: 📊 Upload JUnit test results
+ - name: 📊 Upload JUnit test results and screenshots
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: storybook-junit-results
+ # Ship the raw per-story screenshots alongside the JUnit report so the
+ # Zephyr report workflow can embed them as base64
in each step's
+ # result (replaces the previous S3-upload + URL-in-comment path).
path: |
test-results/storybook-junit.xml
- test-results/screenshot-urls.json
+ test-results/screenshots/
retention-days: 7
- name: 📸 Upload failure artifacts
diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml
index 8497ae05..f6ec87d6 100644
--- a/.github/workflows/zephyr-report-results.yml
+++ b/.github/workflows/zephyr-report-results.yml
@@ -51,6 +51,30 @@ jobs:
- name: Install dependencies
run: yarn install --immutable
+ # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory.
+ # It is deliberately kept OUT of package.json / yarn.lock so public and
+ # external-contributor `yarn install` stays on the public npm registry.
+ # Fetch it here as a leaf tarball and extract into node_modules — this avoids
+ # `npm install`'s full-tree reconciliation, which corrupts the Yarn-managed
+ # node_modules. Its only transitive need (ts-morph) is already installed.
+ - name: Install ts-lib-zephyr-nodejs (JFrog)
+ env:
+ JFROG_NPM_REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }}
+ JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
+ run: |
+ set -euo pipefail
+ host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"
+ npmrc="$RUNNER_TEMP/.npmrc-jfrog"
+ # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for
+ # the read-only Artifactory user (same value used across the org's repos).
+ printf 'registry=%s\n//%s:_auth=%s\nalways-auth=true\n' \
+ "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc"
+ tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY")
+ rm -rf node_modules/ts-lib-zephyr-nodejs
+ mkdir -p node_modules/ts-lib-zephyr-nodejs
+ tar -xzf "$tgz" -C node_modules/ts-lib-zephyr-nodejs --strip-components=1
+ rm -f "$npmrc" "$tgz"
+
- name: Download test results artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
diff --git a/.github/workflows/zephyr-sync-storybook.yml b/.github/workflows/zephyr-sync-storybook.yml
index 3d68f38f..c1577c3f 100644
--- a/.github/workflows/zephyr-sync-storybook.yml
+++ b/.github/workflows/zephyr-sync-storybook.yml
@@ -41,6 +41,30 @@ jobs:
- name: Install dependencies
run: yarn install --immutable
+ # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory.
+ # It is deliberately kept OUT of package.json / yarn.lock so public and
+ # external-contributor `yarn install` stays on the public npm registry.
+ # Fetch it here as a leaf tarball and extract into node_modules — this avoids
+ # `npm install`'s full-tree reconciliation, which corrupts the Yarn-managed
+ # node_modules. Its only transitive need (ts-morph) is already installed.
+ - name: Install ts-lib-zephyr-nodejs (JFrog)
+ env:
+ JFROG_NPM_REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }}
+ JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
+ run: |
+ set -euo pipefail
+ host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"
+ npmrc="$RUNNER_TEMP/.npmrc-jfrog"
+ # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for
+ # the read-only Artifactory user (same value used across the org's repos).
+ printf 'registry=%s\n//%s:_auth=%s\nalways-auth=true\n' \
+ "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc"
+ tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY")
+ rm -rf node_modules/ts-lib-zephyr-nodejs
+ mkdir -p node_modules/ts-lib-zephyr-nodejs
+ tar -xzf "$tgz" -C node_modules/ts-lib-zephyr-nodejs --strip-components=1
+ rm -f "$npmrc" "$tgz"
+
- name: Run Zephyr sync
env:
ZEPHYR_TOKEN: ${{ secrets.ZEPHYR_TOKEN }}
diff --git a/AGENTS.md b/AGENTS.md
index a8b113f0..c70fb2e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -128,7 +128,9 @@ Convention: uses [Conventional Commits](https://www.conventionalcommits.org/) fo
## Zephyr Integration
-- Test results reported to Zephyr Scale via `scripts/zephyr/report-zephyr-results.ts`
+- Zephyr HTTP is handled by a shared internal `ts-lib-zephyr-nodejs` library (`ZephyrClient` + helpers). The repo's scripts are thin wrappers around it — JUnit parsing, story parsing/write-back, cycle resolution, and folder mapping stay local.
+- **Dependency source:** the library lives only on TetraScience's JFrog Artifactory and is deliberately kept **out of `package.json` / `yarn.lock`** so public/external-contributor `yarn install` stays on the public npm registry. CI installs it as a leaf tarball inside the two Zephyr workflows (see `Install ts-lib-zephyr-nodejs (JFrog)` steps; needs `JFROG_ARTIFACTORY_NPM_VIRTUAL_URL` + `JFROG_ARTIFACTORY_READ_NPM_AUTH` secrets). For local script work, install it as a leaf: `npm pack ts-lib-zephyr-nodejs@0.4.0 --registry ` then extract the tgz into `node_modules/ts-lib-zephyr-nodejs` (do **not** `npm install --no-save` — it corrupts the Yarn `node_modules`).
+- Test results reported to Zephyr Scale via `scripts/zephyr/report-zephyr-results.ts`. Each story's screenshot is embedded (base64 `
`) into every step of the execution via the library's `buildStepsPayload` + `putExecutionSteps` — there is no S3 upload; CI ships `test-results/screenshots/**` in the `storybook-junit-results` artifact for the report job to read.
- Story-to-testcase sync handled by `scripts/zephyr/sync-storybook-zephyr.ts`
- Test case IDs live in story parameters: `parameters.zephyr.testCaseId`
- Do not manually invent, copy, reuse, or paste Zephyr test case IDs between stories.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5dee5291..6bb4368a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -282,6 +282,8 @@ export const MyStory: Story = {
#### Zephyr Integration
+The Zephyr scripts are thin wrappers around a shared internal `ts-lib-zephyr-nodejs` library (installed from the internal registry only inside the Zephyr CI workflows — see `AGENTS.md`). Test results report per-story screenshots embedded directly into each Zephyr step.
+
Test cases are linked to Zephyr Scale via story parameters. These IDs are auto-generated by `sync-storybook-zephyr`:
```typescript
diff --git a/package.json b/package.json
index e0cf31be..b619a413 100644
--- a/package.json
+++ b/package.json
@@ -168,8 +168,6 @@
},
"devDependencies": {
"@aws-sdk/client-athena": "^3.975.0",
- "@aws-sdk/client-s3": "^3.985.0",
- "@aws-sdk/s3-request-presigner": "^3.985.0",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@databricks/sql": "^1.12.0",
diff --git a/scripts/upload-test-screenshots.ts b/scripts/upload-test-screenshots.ts
deleted file mode 100644
index 62f7959b..00000000
--- a/scripts/upload-test-screenshots.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-#!/usr/bin/env tsx
-/**
- * Upload test screenshots to S3 organized by Zephyr test case ID
- */
-
-import fs from "fs";
-import path from "path";
-
-import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
-
-// ============================================================================
-// Configuration
-// ============================================================================
-
-// Required environment variables
-const AWS_REGION = process.env.AWS_REGION;
-const S3_BUCKET = process.env.S3_BUCKET;
-
-if (!AWS_REGION) {
- console.error("[ERROR] AWS_REGION environment variable is required");
- process.exit(1);
-}
-
-if (!S3_BUCKET) {
- console.error("[ERROR] S3_BUCKET environment variable is required");
- process.exit(1);
-}
-const SCREENSHOT_DIR = path.resolve(process.cwd(), "test-results/screenshots");
-const OUTPUT_FILE = path.resolve(process.cwd(), "test-results/screenshot-urls.json");
-
-// GitHub context for organizing uploads
-const GITHUB_RUN_NUMBER = process.env.GITHUB_RUN_NUMBER || "local";
-const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || "tetrascience/ts-lib-ui-kit";
-
-// Pattern to extract Zephyr ID from filename: SW-T123.png
-const ZEPHYR_ID_FROM_FILENAME = /^([A-Z]+-T\d+)\.png$/;
-
-// ============================================================================
-// Types
-// ============================================================================
-
-interface ScreenshotUrlMapping {
- zephyrId: string;
- s3Key: string;
- url: string;
-}
-
-interface OutputFile {
- uploadedAt: string;
- bucket: string;
- region: string;
- runNumber: string;
- repository: string;
- screenshots: ScreenshotUrlMapping[];
-}
-
-// ============================================================================
-// S3 Client
-// ============================================================================
-
-const s3Client = new S3Client({ region: AWS_REGION });
-
-// ============================================================================
-// Helper Functions
-// ============================================================================
-
-function getDateString(): string {
- const now = new Date();
- return now.toISOString().split("T")[0]; // YYYY-MM-DD
-}
-
-function getRepoName(): string {
- return GITHUB_REPOSITORY.split("/").pop() || "unknown";
-}
-
-const CLOUDFRONT_DOMAIN = "d3kr1q5iwe1zqc.cloudfront.net";
-
-/**
- * Build artifact URL via CloudFront distribution in front of the S3 bucket.
- */
-function buildArtifactUrl(s3Key: string): string {
- return `https://${CLOUDFRONT_DOMAIN}/${s3Key}`;
-}
-
-/**
- * Upload a screenshot file to S3 for a specific Zephyr ID
- */
-async function uploadScreenshotForZephyrId(screenshotPath: string, zephyrId: string): Promise {
- const repoName = getRepoName();
- const dateStr = getDateString();
-
- // Path structure: {repo}/{date}/{run-number}/{zephyr-id}/screenshot.png
- const s3Key = `${repoName}/${dateStr}/${GITHUB_RUN_NUMBER}/${zephyrId}/screenshot.png`;
-
- console.log(` Uploading: ${zephyrId} -> s3://${S3_BUCKET}/${s3Key}`);
-
- const fileContent = fs.readFileSync(screenshotPath);
-
- const putCommand = new PutObjectCommand({
- Bucket: S3_BUCKET,
- Key: s3Key,
- Body: fileContent,
- ContentType: "image/png",
- // Using bucket policy for public access (no ACL)
- Metadata: {
- "zephyr-id": zephyrId,
- "github-run-number": GITHUB_RUN_NUMBER,
- "github-repository": GITHUB_REPOSITORY,
- "uploaded-at": new Date().toISOString(),
- },
- });
-
- await s3Client.send(putCommand);
-
- return {
- zephyrId,
- s3Key,
- url: buildArtifactUrl(s3Key),
- };
-}
-
-/**
- * Get screenshot files and extract Zephyr IDs from filenames
- */
-function getScreenshotFiles(): { filename: string; zephyrId: string }[] {
- if (!fs.existsSync(SCREENSHOT_DIR)) {
- console.log(`[INFO] Screenshot directory not found: ${SCREENSHOT_DIR}`);
- return [];
- }
-
- return fs
- .readdirSync(SCREENSHOT_DIR)
- .filter((f) => f.endsWith(".png"))
- .map((filename) => {
- const match = filename.match(ZEPHYR_ID_FROM_FILENAME);
- if (match) {
- return { filename, zephyrId: match[1] };
- }
- return null;
- })
- .filter((f): f is { filename: string; zephyrId: string } => f !== null);
-}
-
-// ============================================================================
-// Main
-// ============================================================================
-
-async function main(): Promise {
- console.log("[INFO] Starting screenshot upload to S3");
- console.log(`[INFO] S3 bucket: ${S3_BUCKET}`);
- console.log(`[INFO] AWS region: ${AWS_REGION}`);
- console.log(`[INFO] Run number: ${GITHUB_RUN_NUMBER}`);
- console.log(`[INFO] Screenshot directory: ${SCREENSHOT_DIR}\n`);
-
- // Get screenshot files with Zephyr IDs
- const screenshotFiles = getScreenshotFiles();
- console.log(`[INFO] Found ${screenshotFiles.length} screenshots with Zephyr IDs\n`);
-
- if (screenshotFiles.length === 0) {
- console.log("[INFO] No screenshots to upload. Creating empty mapping file.");
- const emptyOutput: OutputFile = {
- uploadedAt: new Date().toISOString(),
- bucket: S3_BUCKET,
- region: AWS_REGION,
- runNumber: GITHUB_RUN_NUMBER,
- repository: GITHUB_REPOSITORY,
- screenshots: [],
- };
- fs.writeFileSync(OUTPUT_FILE, JSON.stringify(emptyOutput, null, 2));
- return;
- }
-
- const uploadedScreenshots: ScreenshotUrlMapping[] = [];
- let successCount = 0;
- let errorCount = 0;
-
- // Upload each screenshot
- for (const { filename, zephyrId } of screenshotFiles) {
- const screenshotPath = path.join(SCREENSHOT_DIR, filename);
-
- try {
- const result = await uploadScreenshotForZephyrId(screenshotPath, zephyrId);
- uploadedScreenshots.push(result);
- successCount++;
- console.log(` ✓ ${zephyrId}`);
- } catch (error) {
- console.error(` ✗ Failed to upload ${zephyrId}:`, error);
- errorCount++;
- }
- }
-
- // Write output mapping file
- const output: OutputFile = {
- uploadedAt: new Date().toISOString(),
- bucket: S3_BUCKET,
- region: AWS_REGION,
- runNumber: GITHUB_RUN_NUMBER,
- repository: GITHUB_REPOSITORY,
- screenshots: uploadedScreenshots,
- };
-
- fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2));
- console.log(`\n[INFO] Screenshot URL mapping written to: ${OUTPUT_FILE}`);
- console.log(`[INFO] Uploaded: ${successCount}, Errors: ${errorCount}`);
-
- if (errorCount > 0) {
- process.exit(1);
- }
-}
-
-main().catch((error) => {
- console.error("[ERROR] Fatal error:", error);
- process.exit(1);
-});
diff --git a/scripts/zephyr/report-zephyr-results.ts b/scripts/zephyr/report-zephyr-results.ts
index cce02473..959eb34c 100644
--- a/scripts/zephyr/report-zephyr-results.ts
+++ b/scripts/zephyr/report-zephyr-results.ts
@@ -24,27 +24,15 @@ import fs from "fs";
import path from "path";
import { XMLParser } from "fast-xml-parser";
+import {
+ ZephyrClient,
+ buildStepsPayload,
+ stripAnsi,
+ type ZephyrScreenshot,
+ type ZephyrStep,
+} from "ts-lib-zephyr-nodejs";
import { Node, Project, type VariableDeclaration } from "ts-morph";
-// ============================================================================
-// Screenshot URL Mapping Types
-// ============================================================================
-
-interface ScreenshotUrlMapping {
- zephyrId: string;
- s3Key: string;
- url: string;
-}
-
-interface ScreenshotUrlsFile {
- uploadedAt: string;
- bucket: string;
- region: string;
- runNumber: string;
- repository: string;
- screenshots: ScreenshotUrlMapping[];
-}
-
// ============================================================================
// Story to Zephyr ID Mapping
// ============================================================================
@@ -329,6 +317,9 @@ const ZEPHYR_BASE_URL = "https://api.zephyrscale.smartbear.com/v2";
const PROJECT_KEY = process.env.ZEPHYR_PROJECT_KEY || "SW";
const JUNIT_PATH = process.env.JUNIT_PATH || "test-results/storybook-junit.xml";
+/** Directory where the Storybook vitest setup writes per-story screenshots (`.png`). */
+const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR || "test-results/screenshots";
+
function getZephyrToken(): string {
const token = process.env.ZEPHYR_TOKEN;
if (!token) {
@@ -338,6 +329,16 @@ function getZephyrToken(): string {
return token;
}
+/**
+ * Builds the shared Zephyr API client. All HTTP (auth headers, timeouts, error
+ * surfacing, execution/step endpoints) is delegated to `ts-lib-zephyr-nodejs`.
+ * `cycleKey` is resolved per-run and passed on each `postExecution` call, so a
+ * placeholder is fine here.
+ */
+function createZephyrClient(): ZephyrClient {
+ return new ZephyrClient({ baseUrl: ZEPHYR_BASE_URL, apiToken: getZephyrToken(), projectKey: PROJECT_KEY, cycleKey: "" });
+}
+
/** Gets the GitHub Actions run URL from environment variable */
function getGitHubActionsUrl(): string | null {
// GITHUB_ACTIONS_URL is set by the workflow to point to the correct run
@@ -345,50 +346,14 @@ function getGitHubActionsUrl(): string | null {
return process.env.GITHUB_ACTIONS_URL || null;
}
-const SCREENSHOT_URLS_PATH = process.env.SCREENSHOT_URLS_PATH || "test-results/screenshot-urls.json";
-
-/**
- * Loads screenshot URLs from the mapping file created by upload-test-screenshots.ts
- * Returns null if the file doesn't exist or is invalid
- */
-function loadScreenshotUrls(): ScreenshotUrlsFile | null {
- const screenshotUrlsPath = path.resolve(process.cwd(), SCREENSHOT_URLS_PATH);
-
- if (!fs.existsSync(screenshotUrlsPath)) {
- console.log("[INFO] No screenshot URLs file found (screenshots may not have been uploaded)");
- return null;
- }
-
- try {
- const content = fs.readFileSync(screenshotUrlsPath, "utf-8");
- const data = JSON.parse(content) as ScreenshotUrlsFile;
-
- if (!data.screenshots || data.screenshots.length === 0) {
- console.log("[INFO] Screenshot URLs file exists but contains no screenshots");
- return null;
- }
-
- console.log(`[INFO] Loaded ${data.screenshots.length} screenshot URLs from ${screenshotUrlsPath}`);
- return data;
- } catch (error) {
- console.error("[WARN] Failed to parse screenshot URLs file:", error);
- return null;
- }
-}
-
/**
- * Formats a screenshot URL for inclusion in test execution comment
+ * Resolves the on-disk path to a test case's screenshot, or null when absent.
+ * The Storybook vitest setup (`.storybook/vitest.setup.ts`) captures one PNG per
+ * story named `.png`; CI ships this directory alongside the JUnit report.
*/
-function formatScreenshotLinkComment(screenshotUrl: string): string {
- return `Test Screenshot: ${screenshotUrl}`;
-}
-
-/**
- * Finds the screenshot URL for a specific Zephyr test case
- */
-function findScreenshotForTestCase(screenshotUrls: ScreenshotUrlsFile, testCaseKey: string): string | null {
- const screenshot = screenshotUrls.screenshots.find((s) => s.zephyrId === testCaseKey);
- return screenshot?.url || null;
+function findScreenshotPath(testCaseKey: string): string | null {
+ const p = path.resolve(process.cwd(), SCREENSHOT_DIR, `${testCaseKey}.png`);
+ return fs.existsSync(p) ? p : null;
}
/** Validates that a cache file path is safe (no path traversal) */
@@ -419,18 +384,13 @@ export function sanitizeBranchName(branch: string): string {
return branch.replace(/[^a-zA-Z0-9\-_/]/g, "-");
}
-/** Creates a new test cycle and returns its key */
-async function createTestCycle(name: string): Promise {
- const url = `${ZEPHYR_BASE_URL}/testcycles`;
- const response = await fetch(url, {
- method: "POST",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- body: JSON.stringify({ projectKey: PROJECT_KEY, name }),
- });
- if (!response.ok) {
- throw new Error(`Failed to create test cycle: HTTP ${response.status}`);
- }
- const data = await response.json();
+/**
+ * Creates a new test cycle and returns its key.
+ * The library's `ZephyrClient` has no cycle-creation method, so we use its generic
+ * `request()` — this still routes through the shared auth/timeout/error handling.
+ */
+async function createTestCycle(client: ZephyrClient, name: string): Promise {
+ const data = await client.request<{ key: string }>("POST", "/testcycles", { projectKey: PROJECT_KEY, name });
return data.key;
}
@@ -438,7 +398,7 @@ async function createTestCycle(name: string): Promise {
* Determines which test cycle to use based on environment variables.
* Returns the cycle key (existing or newly created) and the source.
*/
-async function resolveCycleKey(): Promise<{ cycleKey: string; source: string }> {
+async function resolveCycleKey(client: ZephyrClient): Promise<{ cycleKey: string; source: string }> {
const cycleKey = process.env.ZEPHYR_CYCLE_KEY?.trim();
const branch = process.env.ZEPHYR_BRANCH?.trim();
const cacheFile = process.env.ZEPHYR_CYCLE_CACHE_FILE || ".zephyr-cycle-key";
@@ -459,7 +419,7 @@ async function resolveCycleKey(): Promise<{ cycleKey: string; source: string }>
const safeBranch = sanitizeBranchName(branch);
const cycleName = `React UI Lib Storybook Tests - ${safeBranch}`;
console.log(`[INFO] Creating test cycle: ${cycleName}`);
- const newKey = await createTestCycle(cycleName);
+ const newKey = await createTestCycle(client, cycleName);
console.log(`[SUCCESS] Created test cycle: ${newKey}`);
// Cache the key for subsequent runs
@@ -595,61 +555,63 @@ export function parseJUnitXML(
return results;
}
-async function reportTestExecution(
- cycleKey: string,
- result: TestResult,
- screenshotUrls: ScreenshotUrlsFile | null,
-): Promise {
- const url = `${ZEPHYR_BASE_URL}/testexecutions`;
- const body: {
- projectKey: string;
- testCycleKey: string;
- testCaseKey: string;
- statusName: string;
- executionTime?: number;
- comment?: string;
- } = {
- projectKey: PROJECT_KEY,
- testCycleKey: cycleKey,
- testCaseKey: result.testCaseKey,
- statusName: result.status,
- };
- if (result.executionTime) body.executionTime = result.executionTime;
-
- // Build comment with GitHub Actions link and screenshot links if available
+/** Builds the execution comment: result message + CI run link. */
+function buildExecutionComment(result: TestResult): string {
const commentParts: string[] = [];
+ if (result.comment) commentParts.push(stripAnsi(result.comment));
- // Add test result comment (failure message, skip reason, etc.)
- if (result.comment) {
- commentParts.push(result.comment);
- }
-
- // Add GitHub Actions link
const githubUrl = getGitHubActionsUrl();
- if (githubUrl) {
- commentParts.push(`View CI run: ${githubUrl}`);
- }
-
- // Add screenshot link for this specific test case if available
- if (screenshotUrls) {
- const screenshotUrl = findScreenshotForTestCase(screenshotUrls, result.testCaseKey);
- if (screenshotUrl) {
- commentParts.push(formatScreenshotLinkComment(screenshotUrl));
- }
- }
+ if (githubUrl) commentParts.push(`View CI run: ${githubUrl}`);
- if (commentParts.length > 0) {
- body.comment = commentParts.join("\n\n");
- }
+ return commentParts.join("\n\n");
+}
- const response = await fetch(url, {
- method: "POST",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!response.ok) {
- throw new Error(`Failed to report execution for ${result.testCaseKey}: HTTP ${response.status}`);
- }
+/**
+ * Reports one test execution to Zephyr and embeds the captured story screenshot
+ * into every step of that execution.
+ *
+ * Flow (mirrors the library's own StorybookZephyrReporter):
+ * 1. POST the execution (creates it + baseline testScriptResults).
+ * 2. Look up the test case's step count and attach the same PNG to each step via
+ * `buildStepsPayload` (base64 `
` embedded in each step's actualResult).
+ * 3. PUT the step payload onto the execution.
+ * Skipped ("Not Executed") tests get no step-image PUT.
+ */
+async function reportTestExecution(client: ZephyrClient, cycleKey: string, result: TestResult): Promise {
+ const comment = buildExecutionComment(result);
+ const stepCount = Math.max(1, await client.getStepCount(result.testCaseKey));
+
+ const { key: executionKey } = await client.postExecution(
+ PROJECT_KEY,
+ result.testCaseKey,
+ cycleKey,
+ result.status,
+ comment,
+ // actualResult: the last step's baseline result before screenshots are merged in
+ result.status === "Pass" ? "Story rendered successfully" : (result.comment ?? result.status),
+ result.executionTime,
+ stepCount,
+ );
+
+ // Embed the captured screenshot into every step (per-step evidence).
+ if (result.status === "Not Executed" || !executionKey) return;
+
+ const screenshotPath = findScreenshotPath(result.testCaseKey);
+ if (!screenshotPath) return;
+
+ const failed = result.status === "Fail";
+ const localSteps: ZephyrStep[] = Array.from({ length: stepCount }, () => ({
+ title: result.testCaseKey,
+ failed,
+ ...(failed && result.comment ? { errorMessage: stripAnsi(result.comment) } : {}),
+ }));
+ const screenshots: ZephyrScreenshot[] = Array.from({ length: stepCount }, (_, i) => ({
+ stepIndex: i,
+ filePath: screenshotPath,
+ }));
+
+ const steps = buildStepsPayload(stepCount, localSteps, screenshots);
+ await client.putExecutionSteps(executionKey, steps);
}
async function main(): Promise {
@@ -671,11 +633,10 @@ async function main(): Promise {
return;
}
- // Load screenshot URLs if available (from S3 upload script)
- const screenshotUrls = loadScreenshotUrls();
+ const client = createZephyrClient();
// Determine the test cycle to use
- const { cycleKey, source } = await resolveCycleKey();
+ const { cycleKey, source } = await resolveCycleKey(client);
console.log(`[INFO] Using test cycle: ${cycleKey} (${source})\n`);
let passCount = 0,
@@ -684,7 +645,7 @@ async function main(): Promise {
for (const result of results) {
try {
- await reportTestExecution(cycleKey, result, screenshotUrls);
+ await reportTestExecution(client, cycleKey, result);
if (result.status === "Pass") passCount++;
else failCount++;
console.log(` [${result.status.toUpperCase()}] ${result.testCaseKey}`);
diff --git a/scripts/zephyr/sync-storybook-zephyr.ts b/scripts/zephyr/sync-storybook-zephyr.ts
index efa78f78..3d3f0d9a 100644
--- a/scripts/zephyr/sync-storybook-zephyr.ts
+++ b/scripts/zephyr/sync-storybook-zephyr.ts
@@ -16,6 +16,7 @@
import fs from "fs";
import path from "path";
+import { ZephyrClient } from "ts-lib-zephyr-nodejs";
import { Project, Node } from "ts-morph";
import type { VariableDeclaration } from "ts-morph";
@@ -55,11 +56,6 @@ export interface DuplicateZephyrId {
stories: Pick[];
}
-interface ZephyrFolder {
- id: string;
- name: string;
- parentId?: string;
-}
interface FolderCache {
[key: string]: string | null;
}
@@ -88,6 +84,24 @@ function getZephyrToken(): string {
}
return token;
}
+
+/**
+ * Lazily-constructed shared Zephyr API client. All HTTP (auth headers, timeouts,
+ * error surfacing, pagination helpers) is delegated to `ts-lib-zephyr-nodejs`.
+ * Provisioning never posts executions, so `cycleKey` is left blank.
+ */
+let zephyrClient: ZephyrClient | null = null;
+function getClient(): ZephyrClient {
+ if (!zephyrClient) {
+ zephyrClient = new ZephyrClient({
+ baseUrl: ZEPHYR_BASE_URL,
+ apiToken: getZephyrToken(),
+ projectKey: PROJECT_KEY,
+ cycleKey: "",
+ });
+ }
+ return zephyrClient;
+}
const ZEPHYR_LABELS = process.env.ZEPHYR_LABELS?.split(",")
.map((l) => l.trim())
.filter(Boolean) || ["storybook", "vitest", "automated"];
@@ -510,18 +524,9 @@ async function getTestCase(testCaseKey: string): Promise {
const cached = testCaseDetailCache.get(testCaseKey);
if (cached) return cached;
- const url = `${ZEPHYR_BASE_URL}/testcases/${testCaseKey}`;
- const response = await fetch(url, {
- method: "GET",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to fetch test case ${testCaseKey}: ${response.status} ${errorText}`);
- }
-
- const testCase = (await response.json()) as ZephyrTestCase;
+ // The library's typed `getTestCase` omits objective/labels (needed for reuse
+ // matching), so use the generic request to fetch the full test case body.
+ const testCase = await getClient().request("GET", `/testcases/${testCaseKey}`);
testCaseDetailCache.set(testCaseKey, testCase);
return testCase;
}
@@ -529,23 +534,16 @@ async function getTestCase(testCaseKey: string): Promise {
async function getGeneratedTestCases(): Promise {
if (generatedTestCaseCache) return generatedTestCaseCache;
+ const client = getClient();
const testCases: ZephyrTestCase[] = [];
let startAt = 0;
let fetchedCount = 0;
while (true) {
- const url = `${ZEPHYR_BASE_URL}/testcases?projectKey=${PROJECT_KEY}&startAt=${startAt}&maxResults=${TEST_CASE_PAGE_SIZE}`;
- const response = await fetch(url, {
- method: "GET",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to list test cases: ${response.status} ${errorText}`);
- }
-
- const page = (await response.json()) as ZephyrTestCaseListResponse;
+ const page = await client.request(
+ "GET",
+ `/testcases?projectKey=${PROJECT_KEY}&startAt=${startAt}&maxResults=${TEST_CASE_PAGE_SIZE}`,
+ );
const values = page.values ?? [];
fetchedCount += values.length;
testCases.push(...values.filter(hasConfiguredLabels));
@@ -562,20 +560,10 @@ async function getExecutionCount(testCaseKey: string): Promise {
const cached = executionCountCache.get(testCaseKey);
if (cached !== undefined) return cached;
- const url = `${ZEPHYR_BASE_URL}/testexecutions?projectKey=${PROJECT_KEY}&testCase=${encodeURIComponent(
- testCaseKey,
- )}&maxResults=1`;
- const response = await fetch(url, {
- method: "GET",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to fetch executions for ${testCaseKey}: ${response.status} ${errorText}`);
- }
-
- const page = (await response.json()) as { total?: number; values?: unknown[] };
+ const page = await getClient().request<{ total?: number; values?: unknown[] }>(
+ "GET",
+ `/testexecutions?projectKey=${PROJECT_KEY}&testCase=${encodeURIComponent(testCaseKey)}&maxResults=1`,
+ );
const count = page.total ?? page.values?.length ?? 0;
executionCountCache.set(testCaseKey, count);
return count;
@@ -614,39 +602,35 @@ async function findReusableTestCase(testName: string, objective: string): Promis
async function getFolders(): Promise {
if (Object.keys(folderIdCache).length > 0) return folderIdCache;
- const url = `${ZEPHYR_BASE_URL}/folders?projectKey=${PROJECT_KEY}&folderType=TEST_CASE&maxResults=100`;
- const response = await fetch(url, {
- method: "GET",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- });
- if (!response.ok) {
+
+ let folders: Array<{ id: number; name: string; parentId: number | null }>;
+ try {
+ folders = await getClient().listFolders(PROJECT_KEY, "TEST_CASE");
+ } catch {
console.warn("Could not fetch folders");
return {};
}
- const data = await response.json();
- const folders: ZephyrFolder[] = data.values || [];
// Find or create the root folder
- let rootFolder = folders.find((f) => f.name === FOLDER_PREFIX && !f.parentId);
- if (!rootFolder) {
+ let rootFolderId = folders.find((f) => f.name === FOLDER_PREFIX && !f.parentId)?.id;
+ if (rootFolderId === undefined) {
try {
- rootFolder = await createFolder(FOLDER_PREFIX);
+ rootFolderId = await createFolder(FOLDER_PREFIX);
console.log(`[INFO] Created root folder: ${FOLDER_PREFIX}`);
} catch (e: unknown) {
console.warn(`[WARN] Could not create root folder ${FOLDER_PREFIX}:`, e instanceof Error ? e.message : String(e));
return {};
}
}
- const rootFolderId = rootFolder.id;
for (const [compType, folderName] of Object.entries(FOLDER_MAPPING)) {
const existing = folders.find((f) => f.name === folderName && f.parentId === rootFolderId);
if (existing) {
- folderIdCache[compType] = existing.id;
+ folderIdCache[compType] = String(existing.id);
} else {
try {
- const newF = await createFolder(folderName, rootFolderId);
- folderIdCache[compType] = newF.id;
+ const newFolderId = await createFolder(folderName, rootFolderId);
+ folderIdCache[compType] = String(newFolderId);
console.log(`[INFO] Created subfolder: ${FOLDER_PREFIX}/${folderName}`);
} catch (e: unknown) {
console.warn(`[WARN] Could not create subfolder ${folderName}:`, e instanceof Error ? e.message : String(e));
@@ -657,24 +641,9 @@ async function getFolders(): Promise {
return folderIdCache;
}
-async function createFolder(folderName: string, parentId?: string): Promise {
- const url = `${ZEPHYR_BASE_URL}/folders`;
- const body: { projectKey: string; name: string; folderType: string; parentId?: string } = {
- projectKey: PROJECT_KEY,
- name: folderName,
- folderType: "TEST_CASE",
- };
- if (parentId) body.parentId = parentId;
- const response = await fetch(url, {
- method: "POST",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to create folder: ${response.status} ${errorText}`);
- }
- return await response.json();
+/** Creates a TEST_CASE folder and returns its numeric id. */
+async function createFolder(folderName: string, parentId?: number): Promise {
+ return getClient().createFolder(PROJECT_KEY, folderName, "TEST_CASE", parentId ?? null);
}
async function getFolderId(componentType: string): Promise {
@@ -683,7 +652,8 @@ async function getFolderId(componentType: string): Promise {
}
async function createTestCase(testName: string, objective: string, folderId: string | null): Promise {
- const url = `${ZEPHYR_BASE_URL}/testcases`;
+ // Uses the generic request (not the library's typed createTestCase) to preserve
+ // this repo's custom objective format and label set.
const body: { projectKey: string; name: string; objective: string; labels: string[]; folderId?: string } = {
projectKey: PROJECT_KEY,
name: testName,
@@ -691,44 +661,13 @@ async function createTestCase(testName: string, objective: string, folderId: str
labels: ZEPHYR_LABELS,
};
if (folderId) body.folderId = folderId;
- const response = await fetch(url, {
- method: "POST",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to create test case: ${response.status} ${errorText}`);
- }
- return await response.json();
+ return getClient().request("POST", "/testcases", body);
}
/** Uploads test steps to a Zephyr test case (overwrites existing steps) */
async function uploadTestSteps(testCaseKey: string, steps: TestStep[]): Promise {
if (steps.length === 0) return;
-
- const url = `${ZEPHYR_BASE_URL}/testcases/${testCaseKey}/teststeps`;
- const body = {
- mode: "OVERWRITE",
- items: steps.map((step) => ({
- inline: {
- description: step.description,
- testData: step.testData,
- expectedResult: step.expectedResult,
- },
- })),
- };
-
- const response = await fetch(url, {
- method: "POST",
- headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to upload test steps: ${response.status} ${errorText}`);
- }
+ await getClient().setTestSteps(testCaseKey, steps);
}
// ============================================================================
diff --git a/yarn.lock b/yarn.lock
index 1cab197f..352f527b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -357,7 +357,7 @@ __metadata:
languageName: node
linkType: hard
-"@aws-sdk/client-s3@npm:^3.983.0, @aws-sdk/client-s3@npm:^3.985.0":
+"@aws-sdk/client-s3@npm:^3.983.0":
version: 3.1021.0
resolution: "@aws-sdk/client-s3@npm:3.1021.0"
dependencies:
@@ -980,22 +980,6 @@ __metadata:
languageName: node
linkType: hard
-"@aws-sdk/s3-request-presigner@npm:^3.985.0":
- version: 3.1021.0
- resolution: "@aws-sdk/s3-request-presigner@npm:3.1021.0"
- dependencies:
- "@aws-sdk/signature-v4-multi-region": "npm:^3.996.15"
- "@aws-sdk/types": "npm:^3.973.6"
- "@aws-sdk/util-format-url": "npm:^3.972.8"
- "@smithy/middleware-endpoint": "npm:^4.4.28"
- "@smithy/protocol-http": "npm:^5.3.12"
- "@smithy/smithy-client": "npm:^4.12.8"
- "@smithy/types": "npm:^4.13.1"
- tslib: "npm:^2.6.2"
- checksum: 10c0/5475fbf393d43401ac600fd8e5f790554b2b3b5cb09264349c0795223922d32627f2348ba1af0de99674d085126e2c9ed852773b1fe65e5bb8114d49b57559e1
- languageName: node
- linkType: hard
-
"@aws-sdk/signature-v4-multi-region@npm:^3.996.15":
version: 3.996.15
resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.15"
@@ -1057,18 +1041,6 @@ __metadata:
languageName: node
linkType: hard
-"@aws-sdk/util-format-url@npm:^3.972.8":
- version: 3.972.8
- resolution: "@aws-sdk/util-format-url@npm:3.972.8"
- dependencies:
- "@aws-sdk/types": "npm:^3.973.6"
- "@smithy/querystring-builder": "npm:^4.2.12"
- "@smithy/types": "npm:^4.13.1"
- tslib: "npm:^2.6.2"
- checksum: 10c0/f0983faf602c21b960570057df141778200314b7f37cfffb8682a0ad8bbbb87a022761000379106d1f22c11c16f4f6ea76e4242aa7d7ba943ad9948ca5f95481
- languageName: node
- linkType: hard
-
"@aws-sdk/util-locate-window@npm:^3.0.0":
version: 3.965.5
resolution: "@aws-sdk/util-locate-window@npm:3.965.5"
@@ -7888,11 +7860,9 @@ __metadata:
dependencies:
"@aws-sdk/client-athena": "npm:^3.975.0"
"@aws-sdk/client-cloudwatch-logs": "npm:>=3.0.0 <4.0.0"
- "@aws-sdk/client-s3": "npm:^3.985.0"
"@aws-sdk/client-sqs": "npm:>=3.0.0 <4.0.0"
"@aws-sdk/client-ssm": "npm:>=3.0.0 <4.0.0"
"@aws-sdk/lib-storage": "npm:>=3.0.0 <4.0.0"
- "@aws-sdk/s3-request-presigner": "npm:^3.985.0"
"@base-ui/react": "npm:^1.2.0"
"@commitlint/cli": "npm:^20.5.0"
"@commitlint/config-conventional": "npm:^20.5.0"
From 80f77167db8db2d7c99d025b722a4eada3b1a981 Mon Sep 17 00:00:00 2001
From: Oseer Williams
<265368733+owilliams-tetrascience@users.noreply.github.com>
Date: Wed, 1 Jul 2026 14:52:09 -0400
Subject: [PATCH 2/6] fix: guard zephyr report job to same-repo triggering runs
Resolve CodeQL actions/untrusted-checkout (critical): the workflow_run-
triggered report job is privileged (ZEPHYR_TOKEN + JFrog secrets) and
checks out workflow_run.head_sha. Require the triggering run to come from
this repository so the checked-out ref is always trusted, never untrusted
fork code. Mirrors the same-repo guard already used in
zephyr-sync-storybook.yml.
Refs: SW-1779
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/zephyr-report-results.yml | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml
index f6ec87d6..f50eb22f 100644
--- a/.github/workflows/zephyr-report-results.yml
+++ b/.github/workflows/zephyr-report-results.yml
@@ -25,11 +25,16 @@ permissions:
jobs:
report:
- # Run on workflow_run (when Storybook Tests completes) or workflow_dispatch (manual)
+ # Run on workflow_run (when Storybook Tests completes) or workflow_dispatch (manual).
+ # For the workflow_run path, require the triggering run to originate from THIS
+ # repository (not a fork). This job is privileged (holds ZEPHYR_TOKEN + JFrog
+ # secrets) and checks out `workflow_run.head_sha`; gating on the head repository
+ # ensures that checked-out ref is always trusted, never untrusted fork code.
if: >
github.event_name == 'workflow_dispatch' ||
- github.event.workflow_run.conclusion == 'success' ||
- github.event.workflow_run.conclusion == 'failure'
+ ((github.event.workflow_run.conclusion == 'success' ||
+ github.event.workflow_run.conclusion == 'failure') &&
+ github.event.workflow_run.head_repository.full_name == github.repository)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
From 6df9f497c772e28cf45236f341c6ebb63c256daf Mon Sep 17 00:00:00 2001
From: Oseer Williams
<265368733+owilliams-tetrascience@users.noreply.github.com>
Date: Wed, 1 Jul 2026 14:53:59 -0400
Subject: [PATCH 3/6] fix: address review feedback on zephyr migration
- JFrog npmrc auth: normalize the registry host (strip trailing slash)
and always emit `///:_auth=...` so authentication works whether
or not the secret URL ends with a slash.
- Root-folder detection in sync-storybook-zephyr now matches
`parentId === null` instead of a falsy check, so a folder id of 0 is
never mistaken for a root folder.
Refs: SW-1779
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/zephyr-report-results.yml | 7 +++++--
.github/workflows/zephyr-sync-storybook.yml | 7 +++++--
scripts/zephyr/sync-storybook-zephyr.ts | 5 +++--
3 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml
index f50eb22f..812d27e0 100644
--- a/.github/workflows/zephyr-report-results.yml
+++ b/.github/workflows/zephyr-report-results.yml
@@ -68,11 +68,14 @@ jobs:
JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
run: |
set -euo pipefail
- host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"
+ # Normalize the host: strip scheme and any trailing slash so the auth
+ # line is always `///:_auth=...` regardless of whether the secret
+ # URL ends with a slash (npm requires the `/` before `:_auth`).
+ host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"; host="${host%/}"
npmrc="$RUNNER_TEMP/.npmrc-jfrog"
# JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for
# the read-only Artifactory user (same value used across the org's repos).
- printf 'registry=%s\n//%s:_auth=%s\nalways-auth=true\n' \
+ printf 'registry=%s\n//%s/:_auth=%s\nalways-auth=true\n' \
"$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc"
tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY")
rm -rf node_modules/ts-lib-zephyr-nodejs
diff --git a/.github/workflows/zephyr-sync-storybook.yml b/.github/workflows/zephyr-sync-storybook.yml
index c1577c3f..858d13b9 100644
--- a/.github/workflows/zephyr-sync-storybook.yml
+++ b/.github/workflows/zephyr-sync-storybook.yml
@@ -53,11 +53,14 @@ jobs:
JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
run: |
set -euo pipefail
- host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"
+ # Normalize the host: strip scheme and any trailing slash so the auth
+ # line is always `///:_auth=...` regardless of whether the secret
+ # URL ends with a slash (npm requires the `/` before `:_auth`).
+ host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"; host="${host%/}"
npmrc="$RUNNER_TEMP/.npmrc-jfrog"
# JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for
# the read-only Artifactory user (same value used across the org's repos).
- printf 'registry=%s\n//%s:_auth=%s\nalways-auth=true\n' \
+ printf 'registry=%s\n//%s/:_auth=%s\nalways-auth=true\n' \
"$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc"
tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY")
rm -rf node_modules/ts-lib-zephyr-nodejs
diff --git a/scripts/zephyr/sync-storybook-zephyr.ts b/scripts/zephyr/sync-storybook-zephyr.ts
index 3d3f0d9a..16acf3ab 100644
--- a/scripts/zephyr/sync-storybook-zephyr.ts
+++ b/scripts/zephyr/sync-storybook-zephyr.ts
@@ -611,8 +611,9 @@ async function getFolders(): Promise {
return {};
}
- // Find or create the root folder
- let rootFolderId = folders.find((f) => f.name === FOLDER_PREFIX && !f.parentId)?.id;
+ // Find or create the root folder. Match on `parentId === null` (not a falsy
+ // check) so a legitimate folder id of 0 is never mistaken for a root folder.
+ let rootFolderId = folders.find((f) => f.name === FOLDER_PREFIX && f.parentId === null)?.id;
if (rootFolderId === undefined) {
try {
rootFolderId = await createFolder(FOLDER_PREFIX);
From 6a24063af7bfab3595b9527e59351fd0d250cf5b Mon Sep 17 00:00:00 2001
From: Oseer Williams
<265368733+owilliams-tetrascience@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:04:07 -0400
Subject: [PATCH 4/6] fix: load ts-lib-zephyr-nodejs via runtime dynamic import
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The unit vitest project includes scripts/**/*.test.ts, and those tests
import the Zephyr scripts. With a static import of the JFrog-only
ts-lib-zephyr-nodejs (which is not installed in the publish/CI jobs, only
in the two Zephyr workflows), Vite's import-analysis failed to resolve it
at test-collection time and broke the publish/build jobs.
Load the library through a runtime dynamic import whose specifier is held
in a variable (so bundlers/Vitest can't statically resolve it) and keep
only type-only imports at module scope. The pure parsing/cache helpers the
unit tests exercise never reach the client code path, so the suite now
loads without the package installed; the library is loaded only when a real
Zephyr run reaches the reporting/provisioning code.
Also drop the `#!/usr/bin/env tsx` shebangs (the scripts run via `npx tsx`,
not directly) — Vite prepended its client import onto the shebang line,
causing a parse error once resolution succeeded.
Refs: SW-1779
Co-Authored-By: Claude Opus 4.8
---
scripts/zephyr/report-zephyr-results.ts | 37 +++++++++++++++++--------
scripts/zephyr/sync-storybook-zephyr.ts | 26 ++++++++++-------
2 files changed, 41 insertions(+), 22 deletions(-)
diff --git a/scripts/zephyr/report-zephyr-results.ts b/scripts/zephyr/report-zephyr-results.ts
index 959eb34c..811ff7f7 100644
--- a/scripts/zephyr/report-zephyr-results.ts
+++ b/scripts/zephyr/report-zephyr-results.ts
@@ -1,4 +1,3 @@
-#!/usr/bin/env tsx
/**
* Report Storybook test execution results to Zephyr Scale
*
@@ -24,15 +23,27 @@ import fs from "fs";
import path from "path";
import { XMLParser } from "fast-xml-parser";
-import {
- ZephyrClient,
- buildStepsPayload,
- stripAnsi,
- type ZephyrScreenshot,
- type ZephyrStep,
-} from "ts-lib-zephyr-nodejs";
import { Node, Project, type VariableDeclaration } from "ts-morph";
+import type { ZephyrClient, ZephyrScreenshot, ZephyrStep } from "ts-lib-zephyr-nodejs";
+
+/**
+ * Lazily loads the JFrog-only `ts-lib-zephyr-nodejs` package at runtime.
+ *
+ * The specifier is held in a variable so bundlers/Vitest can't statically
+ * resolve it at module-load time — this lets the unit test suite (which only
+ * exercises the pure parsing/cache helpers below) import this module even in
+ * environments where the package isn't installed. The library is only actually
+ * loaded when a Zephyr report run reaches the reporting code path.
+ */
+type ZephyrLib = typeof import("ts-lib-zephyr-nodejs");
+let zephyrLibPromise: Promise | null = null;
+function getZephyrLib(): Promise {
+ const spec = "ts-lib-zephyr-nodejs";
+ zephyrLibPromise ??= import(spec) as Promise;
+ return zephyrLibPromise;
+}
+
// ============================================================================
// Story to Zephyr ID Mapping
// ============================================================================
@@ -335,7 +346,8 @@ function getZephyrToken(): string {
* `cycleKey` is resolved per-run and passed on each `postExecution` call, so a
* placeholder is fine here.
*/
-function createZephyrClient(): ZephyrClient {
+async function createZephyrClient(): Promise {
+ const { ZephyrClient } = await getZephyrLib();
return new ZephyrClient({ baseUrl: ZEPHYR_BASE_URL, apiToken: getZephyrToken(), projectKey: PROJECT_KEY, cycleKey: "" });
}
@@ -556,7 +568,7 @@ export function parseJUnitXML(
}
/** Builds the execution comment: result message + CI run link. */
-function buildExecutionComment(result: TestResult): string {
+function buildExecutionComment(result: TestResult, stripAnsi: (text: string) => string): string {
const commentParts: string[] = [];
if (result.comment) commentParts.push(stripAnsi(result.comment));
@@ -578,7 +590,8 @@ function buildExecutionComment(result: TestResult): string {
* Skipped ("Not Executed") tests get no step-image PUT.
*/
async function reportTestExecution(client: ZephyrClient, cycleKey: string, result: TestResult): Promise {
- const comment = buildExecutionComment(result);
+ const { buildStepsPayload, stripAnsi } = await getZephyrLib();
+ const comment = buildExecutionComment(result, stripAnsi);
const stepCount = Math.max(1, await client.getStepCount(result.testCaseKey));
const { key: executionKey } = await client.postExecution(
@@ -633,7 +646,7 @@ async function main(): Promise {
return;
}
- const client = createZephyrClient();
+ const client = await createZephyrClient();
// Determine the test cycle to use
const { cycleKey, source } = await resolveCycleKey(client);
diff --git a/scripts/zephyr/sync-storybook-zephyr.ts b/scripts/zephyr/sync-storybook-zephyr.ts
index 16acf3ab..c1ced325 100644
--- a/scripts/zephyr/sync-storybook-zephyr.ts
+++ b/scripts/zephyr/sync-storybook-zephyr.ts
@@ -1,4 +1,3 @@
-#!/usr/bin/env tsx
/**
* Sync Storybook stories to Zephyr Scale as test cases
*
@@ -16,9 +15,9 @@
import fs from "fs";
import path from "path";
-import { ZephyrClient } from "ts-lib-zephyr-nodejs";
import { Project, Node } from "ts-morph";
+import type { ZephyrClient } from "ts-lib-zephyr-nodejs";
import type { VariableDeclaration } from "ts-morph";
// ============================================================================
@@ -89,10 +88,17 @@ function getZephyrToken(): string {
* Lazily-constructed shared Zephyr API client. All HTTP (auth headers, timeouts,
* error surfacing, pagination helpers) is delegated to `ts-lib-zephyr-nodejs`.
* Provisioning never posts executions, so `cycleKey` is left blank.
+ *
+ * The package specifier is held in a variable so bundlers/Vitest can't statically
+ * resolve this JFrog-only package at module-load time — the unit test suite only
+ * exercises the pure parsing helpers below and never constructs a client, so it
+ * can import this module even where the package isn't installed.
*/
let zephyrClient: ZephyrClient | null = null;
-function getClient(): ZephyrClient {
+async function getClient(): Promise {
if (!zephyrClient) {
+ const spec = "ts-lib-zephyr-nodejs";
+ const { ZephyrClient } = (await import(spec)) as typeof import("ts-lib-zephyr-nodejs");
zephyrClient = new ZephyrClient({
baseUrl: ZEPHYR_BASE_URL,
apiToken: getZephyrToken(),
@@ -526,7 +532,7 @@ async function getTestCase(testCaseKey: string): Promise {
// The library's typed `getTestCase` omits objective/labels (needed for reuse
// matching), so use the generic request to fetch the full test case body.
- const testCase = await getClient().request("GET", `/testcases/${testCaseKey}`);
+ const testCase = await (await getClient()).request("GET", `/testcases/${testCaseKey}`);
testCaseDetailCache.set(testCaseKey, testCase);
return testCase;
}
@@ -534,7 +540,7 @@ async function getTestCase(testCaseKey: string): Promise {
async function getGeneratedTestCases(): Promise {
if (generatedTestCaseCache) return generatedTestCaseCache;
- const client = getClient();
+ const client = await getClient();
const testCases: ZephyrTestCase[] = [];
let startAt = 0;
let fetchedCount = 0;
@@ -560,7 +566,7 @@ async function getExecutionCount(testCaseKey: string): Promise {
const cached = executionCountCache.get(testCaseKey);
if (cached !== undefined) return cached;
- const page = await getClient().request<{ total?: number; values?: unknown[] }>(
+ const page = await (await getClient()).request<{ total?: number; values?: unknown[] }>(
"GET",
`/testexecutions?projectKey=${PROJECT_KEY}&testCase=${encodeURIComponent(testCaseKey)}&maxResults=1`,
);
@@ -605,7 +611,7 @@ async function getFolders(): Promise {
let folders: Array<{ id: number; name: string; parentId: number | null }>;
try {
- folders = await getClient().listFolders(PROJECT_KEY, "TEST_CASE");
+ folders = await (await getClient()).listFolders(PROJECT_KEY, "TEST_CASE");
} catch {
console.warn("Could not fetch folders");
return {};
@@ -644,7 +650,7 @@ async function getFolders(): Promise {
/** Creates a TEST_CASE folder and returns its numeric id. */
async function createFolder(folderName: string, parentId?: number): Promise {
- return getClient().createFolder(PROJECT_KEY, folderName, "TEST_CASE", parentId ?? null);
+ return (await getClient()).createFolder(PROJECT_KEY, folderName, "TEST_CASE", parentId ?? null);
}
async function getFolderId(componentType: string): Promise {
@@ -662,13 +668,13 @@ async function createTestCase(testName: string, objective: string, folderId: str
labels: ZEPHYR_LABELS,
};
if (folderId) body.folderId = folderId;
- return getClient().request("POST", "/testcases", body);
+ return (await getClient()).request("POST", "/testcases", body);
}
/** Uploads test steps to a Zephyr test case (overwrites existing steps) */
async function uploadTestSteps(testCaseKey: string, steps: TestStep[]): Promise {
if (steps.length === 0) return;
- await getClient().setTestSteps(testCaseKey, steps);
+ await (await getClient()).setTestSteps(testCaseKey, steps);
}
// ============================================================================
From 817a82c833fd4069c648fb0f88d681b01bf25a67 Mon Sep 17 00:00:00 2001
From: Oseer Williams
<265368733+owilliams-tetrascience@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:47:56 -0400
Subject: [PATCH 5/6] fix: hardcode JFrog read registry URL in zephyr workflows
JFROG_ARTIFACTORY_NPM_VIRTUAL_URL is an environment-scoped secret
(artifactory-prod), so it does not resolve in the Zephyr jobs (which do
not bind that environment). The virtual registry URL is infra info, not a
credential, so hardcode it and keep only the org-level
JFROG_ARTIFACTORY_READ_NPM_AUTH token as a secret.
Refs: SW-1779
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/zephyr-report-results.yml | 7 ++++++-
.github/workflows/zephyr-sync-storybook.yml | 7 ++++++-
AGENTS.md | 2 +-
3 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml
index 812d27e0..7c02aa19 100644
--- a/.github/workflows/zephyr-report-results.yml
+++ b/.github/workflows/zephyr-report-results.yml
@@ -64,7 +64,12 @@ jobs:
# node_modules. Its only transitive need (ts-morph) is already installed.
- name: Install ts-lib-zephyr-nodejs (JFrog)
env:
- JFROG_NPM_REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }}
+ # Read-only JFrog virtual registry URL. This is infra info, not a
+ # credential, so it is hardcoded rather than pulled from the
+ # environment-scoped JFROG_ARTIFACTORY_NPM_VIRTUAL_URL secret (which is
+ # only visible to jobs bound to the artifactory-prod environment). Only
+ # the read auth token comes from a secret (org-level, no environment).
+ JFROG_NPM_REGISTRY: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/
JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
run: |
set -euo pipefail
diff --git a/.github/workflows/zephyr-sync-storybook.yml b/.github/workflows/zephyr-sync-storybook.yml
index 858d13b9..32758308 100644
--- a/.github/workflows/zephyr-sync-storybook.yml
+++ b/.github/workflows/zephyr-sync-storybook.yml
@@ -49,7 +49,12 @@ jobs:
# node_modules. Its only transitive need (ts-morph) is already installed.
- name: Install ts-lib-zephyr-nodejs (JFrog)
env:
- JFROG_NPM_REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }}
+ # Read-only JFrog virtual registry URL. This is infra info, not a
+ # credential, so it is hardcoded rather than pulled from the
+ # environment-scoped JFROG_ARTIFACTORY_NPM_VIRTUAL_URL secret (which is
+ # only visible to jobs bound to the artifactory-prod environment). Only
+ # the read auth token comes from a secret (org-level, no environment).
+ JFROG_NPM_REGISTRY: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/
JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
run: |
set -euo pipefail
diff --git a/AGENTS.md b/AGENTS.md
index c70fb2e7..062dba34 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -129,7 +129,7 @@ Convention: uses [Conventional Commits](https://www.conventionalcommits.org/) fo
## Zephyr Integration
- Zephyr HTTP is handled by a shared internal `ts-lib-zephyr-nodejs` library (`ZephyrClient` + helpers). The repo's scripts are thin wrappers around it — JUnit parsing, story parsing/write-back, cycle resolution, and folder mapping stay local.
-- **Dependency source:** the library lives only on TetraScience's JFrog Artifactory and is deliberately kept **out of `package.json` / `yarn.lock`** so public/external-contributor `yarn install` stays on the public npm registry. CI installs it as a leaf tarball inside the two Zephyr workflows (see `Install ts-lib-zephyr-nodejs (JFrog)` steps; needs `JFROG_ARTIFACTORY_NPM_VIRTUAL_URL` + `JFROG_ARTIFACTORY_READ_NPM_AUTH` secrets). For local script work, install it as a leaf: `npm pack ts-lib-zephyr-nodejs@0.4.0 --registry ` then extract the tgz into `node_modules/ts-lib-zephyr-nodejs` (do **not** `npm install --no-save` — it corrupts the Yarn `node_modules`).
+- **Dependency source:** the library lives only on TetraScience's JFrog Artifactory and is deliberately kept **out of `package.json` / `yarn.lock`** so public/external-contributor `yarn install` stays on the public npm registry. CI installs it as a leaf tarball inside the two Zephyr workflows (see `Install ts-lib-zephyr-nodejs (JFrog)` steps; the read-only virtual registry URL is hardcoded and only the org-level `JFROG_ARTIFACTORY_READ_NPM_AUTH` token comes from a secret). For local script work, install it as a leaf: `npm pack ts-lib-zephyr-nodejs@0.4.0 --registry ` then extract the tgz into `node_modules/ts-lib-zephyr-nodejs` (do **not** `npm install --no-save` — it corrupts the Yarn `node_modules`).
- Test results reported to Zephyr Scale via `scripts/zephyr/report-zephyr-results.ts`. Each story's screenshot is embedded (base64 `
`) into every step of the execution via the library's `buildStepsPayload` + `putExecutionSteps` — there is no S3 upload; CI ships `test-results/screenshots/**` in the `storybook-junit-results` artifact for the report job to read.
- Story-to-testcase sync handled by `scripts/zephyr/sync-storybook-zephyr.ts`
- Test case IDs live in story parameters: `parameters.zephyr.testCaseId`
From 0f230b684c76b3585f7ff32a772251e46de56955 Mon Sep 17 00:00:00 2001
From: Oseer Williams
<265368733+owilliams-tetrascience@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:48:08 -0400
Subject: [PATCH 6/6] chore: use shared install-jfrog-npm-package action in
zephyr workflows
Replace the duplicated inline JFrog leaf-tarball install steps with the new
tetrascience/ts-ci-cd-lib/install-jfrog-npm-package composite action.
Pinned to the action's PR branch (@install-jfrog-npm-package-action) so it
can be exercised before ts-ci-cd-lib#29 merges; switch to @main once that
lands.
Refs: SW-1779
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/zephyr-report-results.yml | 44 +++++++--------------
.github/workflows/zephyr-sync-storybook.yml | 44 +++++++--------------
2 files changed, 28 insertions(+), 60 deletions(-)
diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml
index 7c02aa19..03963b65 100644
--- a/.github/workflows/zephyr-report-results.yml
+++ b/.github/workflows/zephyr-report-results.yml
@@ -56,37 +56,21 @@ jobs:
- name: Install dependencies
run: yarn install --immutable
- # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory.
- # It is deliberately kept OUT of package.json / yarn.lock so public and
- # external-contributor `yarn install` stays on the public npm registry.
- # Fetch it here as a leaf tarball and extract into node_modules — this avoids
- # `npm install`'s full-tree reconciliation, which corrupts the Yarn-managed
- # node_modules. Its only transitive need (ts-morph) is already installed.
+ # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory
+ # and is deliberately kept OUT of package.json / yarn.lock so public and
+ # external-contributor `yarn install` stays on the public npm registry. The
+ # shared composite action fetches it as a leaf tarball into node_modules
+ # (its only transitive need, ts-morph, is already installed).
+ # NOTE: pinned to the action's PR branch for testing; switch to @main once
+ # tetrascience/ts-ci-cd-lib#29 merges.
- name: Install ts-lib-zephyr-nodejs (JFrog)
- env:
- # Read-only JFrog virtual registry URL. This is infra info, not a
- # credential, so it is hardcoded rather than pulled from the
- # environment-scoped JFROG_ARTIFACTORY_NPM_VIRTUAL_URL secret (which is
- # only visible to jobs bound to the artifactory-prod environment). Only
- # the read auth token comes from a secret (org-level, no environment).
- JFROG_NPM_REGISTRY: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/
- JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
- run: |
- set -euo pipefail
- # Normalize the host: strip scheme and any trailing slash so the auth
- # line is always `///:_auth=...` regardless of whether the secret
- # URL ends with a slash (npm requires the `/` before `:_auth`).
- host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"; host="${host%/}"
- npmrc="$RUNNER_TEMP/.npmrc-jfrog"
- # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for
- # the read-only Artifactory user (same value used across the org's repos).
- printf 'registry=%s\n//%s/:_auth=%s\nalways-auth=true\n' \
- "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc"
- tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY")
- rm -rf node_modules/ts-lib-zephyr-nodejs
- mkdir -p node_modules/ts-lib-zephyr-nodejs
- tar -xzf "$tgz" -C node_modules/ts-lib-zephyr-nodejs --strip-components=1
- rm -f "$npmrc" "$tgz"
+ uses: tetrascience/ts-ci-cd-lib/install-jfrog-npm-package@install-jfrog-npm-package-action
+ with:
+ package: ts-lib-zephyr-nodejs
+ version: "0.4.0"
+ # Read-only virtual registry URL — infra info, not a credential.
+ registry-url: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/
+ auth: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
- name: Download test results artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
diff --git a/.github/workflows/zephyr-sync-storybook.yml b/.github/workflows/zephyr-sync-storybook.yml
index 32758308..2cb62a93 100644
--- a/.github/workflows/zephyr-sync-storybook.yml
+++ b/.github/workflows/zephyr-sync-storybook.yml
@@ -41,37 +41,21 @@ jobs:
- name: Install dependencies
run: yarn install --immutable
- # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory.
- # It is deliberately kept OUT of package.json / yarn.lock so public and
- # external-contributor `yarn install` stays on the public npm registry.
- # Fetch it here as a leaf tarball and extract into node_modules — this avoids
- # `npm install`'s full-tree reconciliation, which corrupts the Yarn-managed
- # node_modules. Its only transitive need (ts-morph) is already installed.
+ # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory
+ # and is deliberately kept OUT of package.json / yarn.lock so public and
+ # external-contributor `yarn install` stays on the public npm registry. The
+ # shared composite action fetches it as a leaf tarball into node_modules
+ # (its only transitive need, ts-morph, is already installed).
+ # NOTE: pinned to the action's PR branch for testing; switch to @main once
+ # tetrascience/ts-ci-cd-lib#29 merges.
- name: Install ts-lib-zephyr-nodejs (JFrog)
- env:
- # Read-only JFrog virtual registry URL. This is infra info, not a
- # credential, so it is hardcoded rather than pulled from the
- # environment-scoped JFROG_ARTIFACTORY_NPM_VIRTUAL_URL secret (which is
- # only visible to jobs bound to the artifactory-prod environment). Only
- # the read auth token comes from a secret (org-level, no environment).
- JFROG_NPM_REGISTRY: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/
- JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
- run: |
- set -euo pipefail
- # Normalize the host: strip scheme and any trailing slash so the auth
- # line is always `///:_auth=...` regardless of whether the secret
- # URL ends with a slash (npm requires the `/` before `:_auth`).
- host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"; host="${host%/}"
- npmrc="$RUNNER_TEMP/.npmrc-jfrog"
- # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for
- # the read-only Artifactory user (same value used across the org's repos).
- printf 'registry=%s\n//%s/:_auth=%s\nalways-auth=true\n' \
- "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc"
- tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY")
- rm -rf node_modules/ts-lib-zephyr-nodejs
- mkdir -p node_modules/ts-lib-zephyr-nodejs
- tar -xzf "$tgz" -C node_modules/ts-lib-zephyr-nodejs --strip-components=1
- rm -f "$npmrc" "$tgz"
+ uses: tetrascience/ts-ci-cd-lib/install-jfrog-npm-package@install-jfrog-npm-package-action
+ with:
+ package: ts-lib-zephyr-nodejs
+ version: "0.4.0"
+ # Read-only virtual registry URL — infra info, not a credential.
+ registry-url: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/
+ auth: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
- name: Run Zephyr sync
env: