From 9ca9cf94f4520e136d2868749fb02e305aea53f5 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 3 Sep 2026 00:49:43 +0100 Subject: [PATCH] feat(release): add Studio dev preview network --- .github/workflows/publish.yml | 32 ++----------- README.md | 3 ++ package-lock.json | 6 +-- package.json | 2 +- scripts/resolve-release-channel.mjs | 53 +++++++++++++++++++++ src/commands/staking/wizard.ts | 4 +- src/lib/actions/BaseAction.ts | 9 +++- tests/actions/customNetworkProfiles.test.ts | 19 +++++++- tests/actions/setNetwork.test.ts | 18 ++++++- tests/actions/staking.test.ts | 1 + tests/actions/stakingWizard.test.ts | 25 ++++++++++ tests/actions/vesting.test.ts | 1 + tests/commands/balances.test.ts | 1 + tests/commands/vesting.test.ts | 1 + tests/release-channel.test.mjs | 36 ++++++++++++++ 15 files changed, 172 insertions(+), 39 deletions(-) create mode 100644 scripts/resolve-release-channel.mjs create mode 100644 tests/release-channel.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9364859a..b732e65f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,8 +9,8 @@ name: Publish Package to NPM # Release. It never bumps or tags by itself. # # Stable tags such as v0.39.2 publish to npm's latest dist-tag. -# Prerelease tags such as v0.40.0-clarke.1 publish to a matching -# channel dist-tag (clarke) and create a GitHub prerelease. +# Prerelease tags such as v0.40.0-rc.3 publish to a matching +# channel dist-tag (rc) and create a GitHub prerelease. # Prereleases must never become latest. on: workflow_dispatch: @@ -47,35 +47,9 @@ jobs: TAG_VERSION="${GITHUB_REF_NAME#v}" PKG_VERSION="$(node -p "require('./package.json').version")" - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Tag ($TAG_VERSION) and package.json ($PKG_VERSION) disagree — refusing to publish." >&2 - echo "Re-cut the release via scripts/release.sh so the tag and the committed version match." >&2 - exit 1 - fi - - IS_PRERELEASE=false - NPM_DIST_TAG=latest - if [[ "$TAG_VERSION" == *-* ]]; then - IS_PRERELEASE=true - PRERELEASE_SUFFIX="${TAG_VERSION#*-}" - PRERELEASE_SUFFIX="${PRERELEASE_SUFFIX%%+*}" - NPM_DIST_TAG="${PRERELEASE_SUFFIX%%.*}" - - if [ -z "$NPM_DIST_TAG" ] || [ "$NPM_DIST_TAG" = "latest" ]; then - echo "Invalid prerelease npm dist-tag: '$NPM_DIST_TAG'" >&2 - exit 1 - fi - - if [[ "$NPM_DIST_TAG" =~ ^v?[0-9] ]]; then - echo "Invalid prerelease npm dist-tag '$NPM_DIST_TAG': dist-tags must not look like versions." >&2 - exit 1 - fi - fi + node scripts/resolve-release-channel.mjs "$TAG_VERSION" "$PKG_VERSION" >> "$GITHUB_OUTPUT" echo "Tag $GITHUB_REF_NAME matches package.json $PKG_VERSION." - echo "npm dist-tag: $NPM_DIST_TAG" - echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" - echo "npm_dist_tag=$NPM_DIST_TAG" >> "$GITHUB_OUTPUT" - run: npm run build diff --git a/README.md b/README.md index 792cdf37..2658970e 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ OPTIONS (add): EXAMPLES: genlayer network set + genlayer network set studio-dev genlayer network set testnet-bradbury genlayer network info genlayer network list @@ -178,6 +179,8 @@ EXAMPLES: Custom networks are stored by alias and can be selected anywhere a built-in network can, via `--network ` (or `genlayer network set `). +The built-in `studio-dev` preview points to `https://studio-dev.genlayer.com/api` +on chain ID `61997`; stable `studionet` remains unchanged. #### Deploy and Call Intelligent Contracts diff --git a/package-lock.json b/package-lock.json index 78332235..1255c131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#8f72796efa6f1f52d420957bd253771c8583ca14", + "genlayer-js": "github:genlayerlabs/genlayer-js#facd9e9dc9a289d0110fe3b5b1a14a2938fe6e01", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -5683,8 +5683,8 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#8f72796efa6f1f52d420957bd253771c8583ca14", - "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#facd9e9dc9a289d0110fe3b5b1a14a2938fe6e01", + "integrity": "sha512-ExvojIzcD0bVUMMJ0ir0dUdnXfPefLPEH92vfGWW2CSUxAGdeHq0tu16PSbIITwdib1bV9cr+Qmbp6JDAVz9Jw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e69eb838..0be1f572 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#8f72796efa6f1f52d420957bd253771c8583ca14", + "genlayer-js": "github:genlayerlabs/genlayer-js#facd9e9dc9a289d0110fe3b5b1a14a2938fe6e01", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", diff --git a/scripts/resolve-release-channel.mjs b/scripts/resolve-release-channel.mjs new file mode 100644 index 00000000..da149b14 --- /dev/null +++ b/scripts/resolve-release-channel.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import {fileURLToPath} from "node:url"; +import {resolve} from "node:path"; + +const DIST_TAG_PATTERN = /^[a-z][a-z0-9._-]*$/i; + +export function resolveReleaseChannel(tagVersion, packageVersion) { + if (!tagVersion || !packageVersion) { + throw new Error("Usage: resolve-release-channel.mjs "); + } + if (tagVersion !== packageVersion) { + throw new Error( + `Tag (${tagVersion}) and package.json (${packageVersion}) disagree — refusing to publish.`, + ); + } + + const prerelease = tagVersion.includes("-"); + if (!prerelease) { + return {isPrerelease: false, npmDistTag: "latest"}; + } + + const suffix = tagVersion.slice(tagVersion.indexOf("-") + 1).split("+", 1)[0]; + const npmDistTag = suffix.split(".", 1)[0]; + if ( + !npmDistTag || + npmDistTag === "latest" || + !DIST_TAG_PATTERN.test(npmDistTag) || + /^v?[0-9]/i.test(npmDistTag) + ) { + throw new Error(`Invalid prerelease npm dist-tag: '${npmDistTag}'`); + } + + return {isPrerelease: true, npmDistTag}; +} + +function main() { + try { + const {isPrerelease, npmDistTag} = resolveReleaseChannel( + process.argv[2], + process.argv[3], + ); + process.stdout.write(`is_prerelease=${isPrerelease}\n`); + process.stdout.write(`npm_dist_tag=${npmDistTag}\n`); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index deab9659..753963a8 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -329,8 +329,8 @@ export class ValidatorWizardAction extends StakingAction { } const currentNetwork = this.getConfigByKey("network"); - // Exclude studionet - not compatible with staking - const excludedNetworks = ["studionet"]; + // Studio deployments are not compatible with staking. + const excludedNetworks = ["studionet", "studio-dev"]; const networks = Object.entries(BUILT_IN_NETWORKS) .filter(([alias]) => !excludedNetworks.includes(alias)) .map(([alias, chain]) => ({ diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 86cdaf7a..d8c08a86 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -5,7 +5,13 @@ import chalk from "chalk"; import inquirer from "inquirer"; import { inspect } from "util"; import {createClient, createAccount} from "genlayer-js"; -import {localnet, studionet, testnetAsimov, testnetBradbury} from "genlayer-js/chains"; +import { + localnet, + studioDevnet, + studionet, + testnetAsimov, + testnetBradbury, +} from "genlayer-js/chains"; import type {GenLayerClient, GenLayerChain, Hash, Address, Account} from "genlayer-js/types"; import { applyCustomNetworkProfile, @@ -23,6 +29,7 @@ import {JsonRpcClient} from "../clients/jsonRpcClient"; export const BUILT_IN_NETWORKS: Record = { "localnet": localnet, "studionet": studionet, + "studio-dev": studioDevnet, "testnet-asimov": testnetAsimov, "testnet-bradbury": testnetBradbury, }; diff --git a/tests/actions/customNetworkProfiles.test.ts b/tests/actions/customNetworkProfiles.test.ts index be3189a5..e218ca27 100644 --- a/tests/actions/customNetworkProfiles.test.ts +++ b/tests/actions/customNetworkProfiles.test.ts @@ -6,7 +6,7 @@ import {tmpdir} from "os"; import {NetworkActions} from "../../src/commands/network/setNetwork"; import {resolveNetwork} from "../../src/lib/actions/BaseAction"; import {parseDeploymentObject} from "../../src/lib/networks/customNetworks"; -import {testnetBradbury} from "genlayer-js/chains"; +import {studioDevnet, studionet, testnetBradbury} from "genlayer-js/chains"; import {StakingAction} from "../../src/commands/staking/StakingAction"; const ADDR_1 = "0x1111111111111111111111111111111111111111"; @@ -42,6 +42,21 @@ describe("custom network profiles", () => { fs.rmSync(tempHome, {recursive: true, force: true}); }); + test("studio-dev is a dedicated built-in without moving stable studionet", () => { + const preview = resolveNetwork("studio-dev"); + const stable = resolveNetwork("studionet"); + + expect(preview).toBe(studioDevnet); + expect(preview.id).toBe(61_997); + expect(preview.rpcUrls.default.http).toEqual(["https://studio-dev.genlayer.com/api"]); + expect(preview.consensusMainContract?.address).toBe(studionet.consensusMainContract?.address); + expect(preview.consensusDataContract?.address).toBe(studionet.consensusDataContract?.address); + + expect(stable).toBe(studionet); + expect(stable.id).toBe(61_999); + expect(stable.rpcUrls.default.http).toEqual(["https://studio.genlayer.com/api"]); + }); + test("network add stores flags-only overrides", async () => { await action.addNetwork("bradbury-clarke", { base: "testnet-bradbury", @@ -177,7 +192,7 @@ describe("custom network profiles", () => { expect(failSpy).toHaveBeenNthCalledWith( 2, "Failed to add custom network profile", - "Base network must be one of: localnet, studionet, testnet-asimov, testnet-bradbury", + "Base network must be one of: localnet, studionet, studio-dev, testnet-asimov, testnet-bradbury", ); expect(failSpy).toHaveBeenNthCalledWith( 3, diff --git a/tests/actions/setNetwork.test.ts b/tests/actions/setNetwork.test.ts index a7698150..e58f292a 100644 --- a/tests/actions/setNetwork.test.ts +++ b/tests/actions/setNetwork.test.ts @@ -2,7 +2,13 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; import {NetworkActions} from "../../src/commands/network/setNetwork"; import {ConfigFileManager} from "../../src/lib/config/ConfigFileManager"; import inquirer from "inquirer"; -import {localnet, studionet, testnetAsimov, testnetBradbury} from "genlayer-js/chains"; +import { + localnet, + studioDevnet, + studionet, + testnetAsimov, + testnetBradbury, +} from "genlayer-js/chains"; vi.mock("../../src/lib/config/ConfigFileManager"); vi.mock("inquirer"); @@ -59,6 +65,15 @@ describe("NetworkActions", () => { ); }); + test("setNetwork method sets studio-dev by alias", async () => { + await networkActions.setNetwork("studio-dev"); + + expect(networkActions["writeConfig"]).toHaveBeenCalledWith("network", "studio-dev"); + expect(networkActions["succeedSpinner"]).toHaveBeenCalledWith( + `Network successfully set to ${studioDevnet.name}`, + ); + }); + test("setNetwork method sets testnet-asimov by name", async () => { await networkActions.setNetwork(testnetAsimov.name); @@ -108,6 +123,7 @@ describe("NetworkActions", () => { choices: [ {name: localnet.name, value: "localnet"}, {name: studionet.name, value: "studionet"}, + {name: studioDevnet.name, value: "studio-dev"}, {name: testnetAsimov.name, value: "testnet-asimov"}, {name: testnetBradbury.name, value: "testnet-bradbury"}, ], diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index a214bc12..5ee03ff6 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -35,6 +35,7 @@ vi.mock("genlayer-js", () => ({ vi.mock("genlayer-js/chains", () => ({ localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studionet.genlayer.com"]}}}, + studioDevnet: {id: 61997, name: "studio-dev", rpcUrls: {default: {http: ["https://studio-dev.genlayer.com/api"]}}}, testnetAsimov: { id: 3, name: "testnet-asimov", diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts index a7c2eaeb..f526b3e8 100644 --- a/tests/actions/stakingWizard.test.ts +++ b/tests/actions/stakingWizard.test.ts @@ -64,6 +64,31 @@ function mockRegistrationHelpers(action: ValidatorWizardAction) { vi.spyOn(action as any, "createVestingValidatorRegistration").mockResolvedValue(mockRegistration); } +describe("ValidatorWizardAction Studio network filtering", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("does not offer stable or preview Studio deployments for staking", async () => { + const action = new ValidatorWizardAction(); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("studio-dev"); + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + selectedNetwork: "testnet-asimov", + }); + + await (action as any).stepNetworkSelection({}, {}); + + const questions = vi.mocked(inquirer.prompt).mock.calls[0][0] as any[]; + const aliases = questions[0].choices.map((choice: {value: string}) => choice.value); + expect(aliases).not.toContain("studionet"); + expect(aliases).not.toContain("studio-dev"); + expect(aliases).toContain("testnet-asimov"); + expect(aliases).toContain("testnet-bradbury"); + }); +}); + describe("ValidatorWizardAction --wallet browser (owner)", () => { let action: ValidatorWizardAction; let sendTransaction: ReturnType; diff --git a/tests/actions/vesting.test.ts b/tests/actions/vesting.test.ts index cbf70285..67b4f8a2 100644 --- a/tests/actions/vesting.test.ts +++ b/tests/actions/vesting.test.ts @@ -25,6 +25,7 @@ vi.mock("genlayer-js", () => ({ vi.mock("genlayer-js/chains", () => ({ localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studionet.genlayer.com"]}}}, + studioDevnet: {id: 61997, name: "studio-dev", rpcUrls: {default: {http: ["https://studio-dev.genlayer.com/api"]}}}, testnetAsimov: { id: 3, name: "testnet-asimov", diff --git a/tests/commands/balances.test.ts b/tests/commands/balances.test.ts index eb2d3f36..8ee0d623 100644 --- a/tests/commands/balances.test.ts +++ b/tests/commands/balances.test.ts @@ -14,6 +14,7 @@ vi.mock("genlayer-js", () => ({ vi.mock("genlayer-js/chains", () => ({ localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studio.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, + studioDevnet: {id: 61997, name: "studio-dev", rpcUrls: {default: {http: ["https://studio-dev.genlayer.com/api"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://rpc-asimov.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://rpc-bradbury.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, })); diff --git a/tests/commands/vesting.test.ts b/tests/commands/vesting.test.ts index 70240a94..aeaadfcf 100644 --- a/tests/commands/vesting.test.ts +++ b/tests/commands/vesting.test.ts @@ -22,6 +22,7 @@ vi.mock("genlayer-js", () => ({ vi.mock("genlayer-js/chains", () => ({ localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studio.genlayer.com"]}}}, + studioDevnet: {id: 61997, name: "studio-dev", rpcUrls: {default: {http: ["https://studio-dev.genlayer.com/api"]}}}, testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, })); diff --git a/tests/release-channel.test.mjs b/tests/release-channel.test.mjs new file mode 100644 index 00000000..fe195232 --- /dev/null +++ b/tests/release-channel.test.mjs @@ -0,0 +1,36 @@ +import {describe, expect, it} from "vitest"; +import {resolveReleaseChannel} from "../scripts/resolve-release-channel.mjs"; + +describe("release channel policy", () => { + it("publishes stable releases to latest", () => { + expect(resolveReleaseChannel("0.40.0", "0.40.0")).toEqual({ + isPrerelease: false, + npmDistTag: "latest", + }); + }); + + it("publishes v0.40.0-rc.3 to rc without moving latest", () => { + expect(resolveReleaseChannel("0.40.0-rc.3", "0.40.0-rc.3")).toEqual({ + isPrerelease: true, + npmDistTag: "rc", + }); + }); + + it("rejects mismatched tags", () => { + expect(() => resolveReleaseChannel("0.40.0-rc.3", "0.40.0-clarke.4")).toThrow( + "disagree", + ); + }); + + it("rejects prereleases that could target latest", () => { + expect(() => resolveReleaseChannel("0.40.0-latest.1", "0.40.0-latest.1")).toThrow( + "Invalid prerelease npm dist-tag", + ); + }); + + it("rejects version-like prerelease channels", () => { + expect(() => resolveReleaseChannel("0.40.0-2.1", "0.40.0-2.1")).toThrow( + "Invalid prerelease npm dist-tag", + ); + }); +});