From 9b27ab32dab88c3a1af685442af090623e5fa963 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 3 Sep 2026 00:49:41 +0100 Subject: [PATCH 1/3] feat(chains): add Studio devnet preset --- genlayer_py/accounts/actions.py | 6 +-- genlayer_py/chains/__init__.py | 9 +++- genlayer_py/chains/actions.py | 7 +-- genlayer_py/chains/studio_devnet.py | 32 +++++++++++++ genlayer_py/chains/utils.py | 12 +++++ genlayer_py/contracts/actions.py | 23 +++++----- genlayer_py/transactions/actions.py | 8 ++-- tests/unit/chains/test_chain_actions.py | 17 +++++++ tests/unit/chains/test_chain_presets.py | 46 +++++++++++++++++++ tests/unit/contracts/test_contract_actions.py | 3 +- .../test_transaction_data_read.py | 7 +-- 11 files changed, 142 insertions(+), 28 deletions(-) create mode 100644 genlayer_py/chains/studio_devnet.py create mode 100644 genlayer_py/chains/utils.py create mode 100644 tests/unit/chains/test_chain_presets.py diff --git a/genlayer_py/accounts/actions.py b/genlayer_py/accounts/actions.py index e2d8c57..56ae085 100644 --- a/genlayer_py/accounts/actions.py +++ b/genlayer_py/accounts/actions.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from genlayer_py.chains import localnet +from genlayer_py.chains.utils import is_studio_chain from hexbytes import HexBytes from web3.types import Nonce, BlockIdentifier, ENS from genlayer_py.exceptions import GenLayerError @@ -18,8 +18,8 @@ def fund_account( self: GenLayerClient, address: Union[Address, ChecksumAddress, ENS], amount: int ) -> HexBytes: - if self.chain.id != localnet.id: - raise GenLayerError("Client is not connected to the localhost") + if not is_studio_chain(self.chain): + raise GenLayerError("Account funding is only supported on Studio networks") try: response = self.provider.make_request( method="sim_fundAccount", diff --git a/genlayer_py/chains/__init__.py b/genlayer_py/chains/__init__.py index 9ff2d87..15daadc 100644 --- a/genlayer_py/chains/__init__.py +++ b/genlayer_py/chains/__init__.py @@ -2,5 +2,12 @@ from .testnet_asimov import testnet_asimov from .testnet_bradbury import testnet_bradbury from .studionet import studionet +from .studio_devnet import studio_devnet -__all__ = ["localnet", "testnet_asimov", "testnet_bradbury", "studionet"] +__all__ = [ + "localnet", + "testnet_asimov", + "testnet_bradbury", + "studionet", + "studio_devnet", +] diff --git a/genlayer_py/chains/actions.py b/genlayer_py/chains/actions.py index d387ab8..1c45cf8 100644 --- a/genlayer_py/chains/actions.py +++ b/genlayer_py/chains/actions.py @@ -1,9 +1,8 @@ from __future__ import annotations from genlayer_py.exceptions import GenLayerError -from .localnet import localnet -from .studionet import studionet from .testnet_asimov import testnet_asimov +from .utils import is_studio_chain from typing import TYPE_CHECKING @@ -23,12 +22,10 @@ def initialize_consensus_smart_contract( and bool(self.chain.consensus_main_contract.get("address")) and bool(self.chain.consensus_main_contract.get("abi")) ) - is_local_or_studio_chain = self.chain.id in (localnet.id, studionet.id) - if ( not force_reset and has_static_consensus_contract - and not is_local_or_studio_chain + and not is_studio_chain(self.chain) ): return diff --git a/genlayer_py/chains/studio_devnet.py b/genlayer_py/chains/studio_devnet.py new file mode 100644 index 0000000..cd3da4c --- /dev/null +++ b/genlayer_py/chains/studio_devnet.py @@ -0,0 +1,32 @@ +from genlayer_py.types import GenLayerChain, NativeCurrency + +from .studionet import ( + CONSENSUS_DATA_CONTRACT, + CONSENSUS_MAIN_CONTRACT, +) + + +STUDIO_DEVNET_JSON_RPC_URL = "https://studio-dev.genlayer.com/api" +STUDIO_DEVNET_EXPLORER_URL = "https://explorer-studio-dev.genlayer.com" + +studio_devnet: GenLayerChain = GenLayerChain( + id=61997, + name="GenLayer Studio Devnet", + rpc_urls={"default": {"http": [STUDIO_DEVNET_JSON_RPC_URL]}}, + native_currency=NativeCurrency(name="GEN Token", symbol="GEN", decimals=18), + block_explorers={ + "default": { + "name": "GenLayer Explorer", + "url": STUDIO_DEVNET_EXPLORER_URL, + } + }, + testnet=True, + consensus_main_contract=dict(CONSENSUS_MAIN_CONTRACT), + consensus_data_contract=dict(CONSENSUS_DATA_CONTRACT), + fee_manager_contract=None, + rounds_storage_contract=None, + appeals_contract=None, + staking_contract=None, + default_number_of_initial_validators=5, + default_consensus_max_rotations=3, +) diff --git a/genlayer_py/chains/utils.py b/genlayer_py/chains/utils.py new file mode 100644 index 0000000..be7c5ae --- /dev/null +++ b/genlayer_py/chains/utils.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from genlayer_py.types import GenLayerChain + + +STUDIO_CHAIN_IDS = frozenset({61997, 61999}) + + +def is_studio_chain(chain: GenLayerChain) -> bool: + """Return whether *chain* uses the Studio simulator RPC surface.""" + + return chain.id in STUDIO_CHAIN_IDS diff --git a/genlayer_py/contracts/actions.py b/genlayer_py/contracts/actions.py index 23661c8..96dfa64 100644 --- a/genlayer_py/contracts/actions.py +++ b/genlayer_py/contracts/actions.py @@ -14,7 +14,7 @@ from genlayer_py.exceptions import GenLayerError from genlayer_py.abi import calldata from genlayer_py.abi.transactions import serialize -from genlayer_py.chains import localnet +from genlayer_py.chains.utils import is_studio_chain from web3.constants import ADDRESS_ZERO from web3.logs import DISCARD from genlayer_py.contracts.utils import make_calldata_object @@ -57,7 +57,7 @@ def get_contract_schema( self: GenLayerClient, address: Union[Address, ChecksumAddress], ) -> ContractSchema: - if self.chain.id != localnet.id: + if not is_studio_chain(self.chain): raise GenLayerError("Contract schema is not supported on this network") response = self.provider.make_request( @@ -70,7 +70,7 @@ def get_contract_schema_for_code( self: GenLayerClient, contract_code: AnyStr, ) -> ContractSchema: - if self.chain.id != localnet.id: + if not is_studio_chain(self.chain): raise GenLayerError("Contract schema is not supported on this network") code_bytes = ( @@ -572,10 +572,9 @@ def _resolve_appeal_parameters( def _is_studio_chain(self: GenLayerClient) -> bool: """Reports whether the client targets the studio-embedded consensus. - localnet and studionet share chain id 61999, the same check - ``transactions.actions.get_transaction`` uses to take its studio path. + This includes local Studio, the stable hosted Studio, and preview Studio. """ - return self.chain.id == localnet.id + return is_studio_chain(self.chain) def _to_bytes32(self: GenLayerClient, hex_str: HexStr) -> bytes: @@ -598,8 +597,8 @@ def simulate_write_contract( sim_config: Optional[SimConfig] = None, transaction_hash_variant: TransactionHashVariant = TransactionHashVariant.LATEST_NONFINAL, ) -> dict: - if self.chain.id != localnet.id: - raise GenLayerError("Client is not connected to the localnet") + if not is_studio_chain(self.chain): + raise GenLayerError("Simulation is only supported on Studio networks") if account is None and self.local_account is None: raise GenLayerError("No account provided and no account is connected") sender_address = self.local_account.address if account is None else account.address @@ -972,8 +971,10 @@ def estimate_transaction_fees_for_write( sim_config: Optional[SimConfig] = None, transaction_hash_variant: TransactionHashVariant = TransactionHashVariant.LATEST_NONFINAL, ) -> TransactionFeeEstimate: - if self.chain.id != localnet.id: - raise GenLayerError("Target write fee estimation is only supported on localnet") + if not is_studio_chain(self.chain): + raise GenLayerError( + "Target write fee estimation is only supported on Studio networks" + ) if account is None and self.local_account is None: raise GenLayerError("No account provided and no account is connected") @@ -1148,7 +1149,7 @@ def _prepare_transaction( nonce = self.get_current_nonce(address=sender) - if self.chain.id != localnet.id: + if not is_studio_chain(self.chain): latest_block = self.w3.eth.get_block("latest") base_fee = latest_block["baseFeePerGas"] priority_fee = self.w3.to_wei(2, "gwei") diff --git a/genlayer_py/transactions/actions.py b/genlayer_py/transactions/actions.py index 021d9fc..4d872f5 100644 --- a/genlayer_py/transactions/actions.py +++ b/genlayer_py/transactions/actions.py @@ -33,7 +33,7 @@ from typing import TYPE_CHECKING from genlayer_py.types import GenLayerTransaction, GenLayerRawTransaction import time -from genlayer_py.chains import localnet +from genlayer_py.chains.utils import is_studio_chain from genlayer_py.utils.jsonifier import ( calldata_to_user_friendly_json, result_to_user_friendly_json, @@ -638,7 +638,7 @@ def get_transaction_lifecycle( discriminated ``lifecycle`` returned by :func:`get_transaction`. """ - if self.chain.id == localnet.id: + if is_studio_chain(self.chain): tx_id = ( Web3.to_hex(transaction_hash) if isinstance(transaction_hash, bytes) @@ -683,7 +683,7 @@ def get_transaction( self: GenLayerClient, transaction_hash: _Hash32, ) -> GenLayerTransaction: - if self.chain.id == localnet.id: + if is_studio_chain(self.chain): transaction = self.provider.make_request( method="eth_getTransactionByHash", params=[transaction_hash] )["result"] @@ -803,7 +803,7 @@ def get_triggered_transaction_ids( self: GenLayerClient, transaction_hash: _Hash32, ) -> List[HexStr]: - if self.chain.id == localnet.id: + if is_studio_chain(self.chain): tx = get_transaction(self, transaction_hash) return tx.get("triggered_transactions", []) diff --git a/tests/unit/chains/test_chain_actions.py b/tests/unit/chains/test_chain_actions.py index 2e9e9b8..6c07f72 100644 --- a/tests/unit/chains/test_chain_actions.py +++ b/tests/unit/chains/test_chain_actions.py @@ -5,6 +5,7 @@ from genlayer_py.chains.actions import initialize_consensus_smart_contract from genlayer_py.chains.localnet import localnet +from genlayer_py.chains.studio_devnet import studio_devnet from genlayer_py.exceptions import GenLayerError @@ -45,6 +46,22 @@ def test_initialize_consensus_refreshes_runtime_contract_for_local_chain(): assert getattr(client.chain, "__consensus_abi_fetched_from_rpc") is True +def test_initialize_consensus_refreshes_runtime_contract_for_studio_devnet(): + client = _make_client( + chain_id=studio_devnet.id, + consensus_main_contract={"address": "0x1", "abi": [{"type": "function"}]}, + ) + rpc_contract = {"address": "0x2", "abi": [{"type": "function", "name": "foo"}]} + client.provider.make_request.return_value = {"result": rpc_contract} + + initialize_consensus_smart_contract(self=client) + + client.provider.make_request.assert_called_once_with( + method="sim_getConsensusContract", params=["ConsensusMain"] + ) + assert client.chain.consensus_main_contract == rpc_contract + + def test_initialize_consensus_falls_back_to_static_contract_on_local_rpc_failure(): static_contract = {"address": "0x1", "abi": [{"type": "function"}]} client = _make_client( diff --git a/tests/unit/chains/test_chain_presets.py b/tests/unit/chains/test_chain_presets.py new file mode 100644 index 0000000..a79e95d --- /dev/null +++ b/tests/unit/chains/test_chain_presets.py @@ -0,0 +1,46 @@ +import genlayer_py + +from genlayer_py.chains import __all__ as chain_exports +from genlayer_py.chains.studio_devnet import ( + STUDIO_DEVNET_EXPLORER_URL, + STUDIO_DEVNET_JSON_RPC_URL, + studio_devnet, +) +from genlayer_py.chains.studionet import studionet +from genlayer_py.chains.testnet_asimov import testnet_asimov +from genlayer_py.chains.utils import is_studio_chain + + +def test_studio_devnet_is_exported_with_canonical_preview_coordinates(): + assert "studio_devnet" in chain_exports + assert genlayer_py.studio_devnet is studio_devnet + assert studio_devnet.id == 61997 + assert studio_devnet.name == "GenLayer Studio Devnet" + assert studio_devnet.rpc_urls == { + "default": {"http": ["https://studio-dev.genlayer.com/api"]} + } + assert STUDIO_DEVNET_JSON_RPC_URL == "https://studio-dev.genlayer.com/api" + assert STUDIO_DEVNET_EXPLORER_URL == "https://explorer-studio-dev.genlayer.com" + assert studio_devnet.block_explorers == { + "default": { + "name": "GenLayer Explorer", + "url": "https://explorer-studio-dev.genlayer.com", + } + } + assert studio_devnet.consensus_main_contract == studionet.consensus_main_contract + assert studio_devnet.consensus_data_contract == studionet.consensus_data_contract + assert studio_devnet.consensus_main_contract is not studionet.consensus_main_contract + assert studio_devnet.consensus_data_contract is not studionet.consensus_data_contract + + +def test_stable_studionet_coordinates_do_not_drift_with_preview_preset(): + assert studionet.id == 61999 + assert studionet.rpc_urls == { + "default": {"http": ["https://studio.genlayer.com/api"]} + } + + +def test_studio_chain_classification_includes_preview_but_not_public_testnet(): + assert is_studio_chain(studionet) + assert is_studio_chain(studio_devnet) + assert not is_studio_chain(testnet_asimov) diff --git a/tests/unit/contracts/test_contract_actions.py b/tests/unit/contracts/test_contract_actions.py index 0a6d4aa..03c706a 100644 --- a/tests/unit/contracts/test_contract_actions.py +++ b/tests/unit/contracts/test_contract_actions.py @@ -8,6 +8,7 @@ import genlayer_py.contracts.actions as contract_actions from genlayer_py.chains import localnet +from genlayer_py.chains.studio_devnet import studio_devnet from genlayer_py.chains.testnet_asimov import testnet_asimov from genlayer_py.consensus.abi import CONSENSUS_MAIN_ABI from genlayer_py.exceptions import GenLayerError @@ -1031,7 +1032,7 @@ def test_simulate_write_contract_passes_fee_policy_and_value_to_sim_call(): } ) client = SimpleNamespace( - chain=SimpleNamespace(id=localnet.id), + chain=SimpleNamespace(id=studio_devnet.id), local_account=SimpleNamespace(address=SENDER), provider=SimpleNamespace(make_request=make_request), ) diff --git a/tests/unit/transactions/test_transaction_data_read.py b/tests/unit/transactions/test_transaction_data_read.py index ee15ab2..1e5b135 100644 --- a/tests/unit/transactions/test_transaction_data_read.py +++ b/tests/unit/transactions/test_transaction_data_read.py @@ -25,7 +25,7 @@ ResolutionAction, ResolutionSource, ) -from genlayer_py.chains import localnet +from genlayer_py.chains import localnet, studio_devnet TX_HASH = "0x" + "ab" * 32 CONSENSUS_DATA_ADDRESS = "0x" + "11" * 20 @@ -299,7 +299,8 @@ def test_advanced_transaction_lifecycle_keeps_projection_explicit(): assert not any(call[0] == "canFinalize" for call in lifecycle_calls) -def test_local_transaction_lifecycle_decodes_the_exact_node_rpc_schema(): +@pytest.mark.parametrize("chain_id", [localnet.id, studio_devnet.id]) +def test_studio_transaction_lifecycle_decodes_the_exact_node_rpc_schema(chain_id): provider = Mock() provider.make_request.return_value = { "result": { @@ -317,7 +318,7 @@ def test_local_transaction_lifecycle_decodes_the_exact_node_rpc_schema(): } } client = SimpleNamespace( - chain=SimpleNamespace(id=localnet.id), + chain=SimpleNamespace(id=chain_id), provider=provider, ) From 0c32c2c37f9bc4790dba1124a1069a7991fd2ed1 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 3 Sep 2026 00:49:46 +0100 Subject: [PATCH 2/3] fix(release): support dev-line release candidates --- .claude/skills/release/SKILL.md | 23 +++-- .github/workflows/publish.yml | 31 +++++-- CONTRIBUTING.md | 4 +- docs/BRANCHING.md | 7 +- releaserc.toml | 11 ++- scripts/release.sh | 130 ++++++++++++++++++----------- scripts/release_version.py | 124 +++++++++++++++++++++++++++ tests/unit/test_release_version.py | 63 ++++++++++++++ 8 files changed, 317 insertions(+), 76 deletions(-) create mode 100644 scripts/release_version.py create mode 100644 tests/unit/test_release_version.py diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7221bc0..76bb3b0 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,11 +1,11 @@ --- name: release -description: Cut a release of genlayer-py. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. Use when a human asks "release v0.18.x" or "ship a new version". +description: Cut a release or release candidate of genlayer-py. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. --- # Release skill — genlayer-py -This repo follows a branch-per-major release model. There is no auto-bump on push. A release happens when a human (or you on their behalf) runs `scripts/release.sh` on the target stable branch. +This repo follows a branch-per-release-line model. There is no auto-bump on push. A final release is cut from its stable branch; an RC is cut from the matching `*-dev` integration branch. ## When to use this skill @@ -18,12 +18,13 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re ## What this repo's release model expects -- Branches are named after the major they ship: `v0.18` (current stable). When `v0.19` opens, the previous `v0.18` stays read-only for back-ports. +- Branches are named after the release line they ship: `v0.18` (stable) and `v0.19-dev` (integration). When `v0.19` becomes stable, the previous `v0.18` stays available for back-ports. - Tags live within those branches: `v0.18.1`, `v0.18.2`, ... - **Semver-zero rule**: this package is still on a 0.x line, so the MINOR component is the breaking-change boundary. `0.18 → 0.19` IS a major bump. `scripts/release.sh` refuses both `minor` and `major` keywords without `--allow-major` while we're on 0.x. - A major (= minor on 0.x) bump means cutting a new branch (`v0.19`) — not tagging on top of the current one. -- `CHANGELOG.md` is updated in the release commit (python-semantic-release with explicit version). -- `publish.yml` fires on the tag push and does the PyPI publish + GitHub Release. +- `CHANGELOG.md` is updated in the release commit by python-semantic-release; an explicit requested version must match the version computed from release history and conventional commits. +- Final tags are cut from `vX.Y`; RC tags such as `v0.19.0-rc.1` are cut from `vX.Y-dev`. +- `publish.yml` verifies that the tag is the current owning branch head, publishes to PyPI, and marks RC GitHub Releases as prereleases. ## Steps @@ -31,13 +32,15 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re - Which version? If unspecified, ask whether it's patch or explicit. - If they say "minor" or "major" while we're on 0.x, surface that this means cutting a new branch — confirm before proceeding. -2. **Switch to the target branch + sync.** +2. **Switch to the owning branch + sync.** ```bash git checkout v0.18 git pull --ff-only origin v0.18 ``` If the working tree isn't clean, stop and surface what's there. + For `v0.19.0-rc.1`, use `v0.19-dev` instead. The script rejects final versions on a dev branch and prereleases on a stable branch. + 3. **Verify the head is shippable.** - Latest CI green: ```bash @@ -50,9 +53,10 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re 4. **Run the release script.** ```bash - scripts/release.sh # or patch + scripts/release.sh # final on vX.Y + scripts/release.sh --allow-major # first RC of a new 0.x line ``` - It bumps `pyproject.toml`, updates `CHANGELOG.md`, commits `chore(release): vX.Y.Z`, tags `vX.Y.Z`, and pushes both the branch commit and the tag. It will NOT publish to PyPI — CI handles that. + First run the same command with `--dry-run`; it exercises all read-only preflight and version-policy checks. The real command bumps `pyproject.toml`, updates `CHANGELOG.md`, commits `chore(release): X.Y.Z`, tags `vX.Y.Z`, and pushes both the branch commit and the tag. It will NOT publish to PyPI — CI handles that. 5. **Watch the publish workflow.** ```bash @@ -68,8 +72,9 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re ## Things to refuse -- **Minor or major bump on 0.x without `--allow-major`**. Those are major bumps in semver-zero and belong on a new branch. +- **Minor or major bump on 0.x without `--allow-major`**. Those are major bumps in semver-zero and belong on a new stable/dev branch pair. - **Releasing from `main`** — `main` is retired. +- **A final tag from `*-dev`, or an RC tag from the stable branch** — the tag must belong to the exact owning branch. - **Hand-editing `pyproject.toml` to bump the version** — the script keeps pyproject, the CHANGELOG entry, the commit message, and the tag in lockstep. - **Publishing a tag where `publish.yml` failed** — fix the underlying issue, re-cut the release (delete the bad tag locally and on origin, re-run the script). diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c33afa3..5fa301f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,17 +1,19 @@ name: Publish Package to PyPI # Tag-driven publish. The release is cut by a human (or Claude via the -# release skill) running scripts/release.sh on the target stable branch +# release skill) running scripts/release.sh on the owning version branch # — that script bumps pyproject.toml, updates CHANGELOG.md, commits, # tags vX.Y.Z, and pushes both the branch commit and the tag. This # workflow fires on the tag push, runs tests, sanity-checks the tag # matches pyproject.toml, builds, and publishes to PyPI. It never # bumps or tags by itself. on: - workflow_dispatch: push: tags: - - "v*" + - "v*.*.*" + +permissions: + contents: write jobs: run-tests: @@ -33,16 +35,22 @@ jobs: - name: Install Python run: uv python install 3.12 - - name: Verify tag matches pyproject.toml version + - name: Verify tag, package version, and owning branch run: | TAG_VERSION="${GITHUB_REF_NAME#v}" PKG_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Tag ($TAG_VERSION) and pyproject.toml ($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 + NORMALIZED_VERSION="$(python scripts/release_version.py verify-tag "$TAG_VERSION" "$PKG_VERSION")" + EXPECTED_BRANCH="$(python scripts/release_version.py branch "$TAG_VERSION")" + git fetch --no-tags origin \ + "refs/heads/$EXPECTED_BRANCH:refs/remotes/origin/$EXPECTED_BRANCH" + TAG_COMMIT="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")" + BRANCH_HEAD="$(git rev-parse "origin/$EXPECTED_BRANCH")" + if [ "$TAG_COMMIT" != "$BRANCH_HEAD" ]; then + echo "Tag $GITHUB_REF_NAME points to $TAG_COMMIT, but $EXPECTED_BRANCH is at $BRANCH_HEAD." >&2 + echo "Re-cut the release from the current owning branch head via scripts/release.sh." >&2 exit 1 fi - echo "Tag $GITHUB_REF_NAME matches pyproject.toml $PKG_VERSION." + echo "Tag $GITHUB_REF_NAME matches package $NORMALIZED_VERSION and $EXPECTED_BRANCH@$BRANCH_HEAD." - name: Clean previous builds run: rm -rf -- dist build *.egg-info @@ -63,6 +71,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + RELEASE_FLAGS=() + if [ "$(python scripts/release_version.py is-prerelease "$GITHUB_REF_NAME")" = "true" ]; then + RELEASE_FLAGS+=(--prerelease) + fi NOTES="$(awk -v ver="$GITHUB_REF_NAME" ' $0 ~ "^## \\[?" substr(ver, 2) {capture=1; next} capture && /^## / {exit} @@ -73,4 +85,5 @@ jobs: fi gh release create "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \ - --notes "$NOTES" + --notes "$NOTES" \ + "${RELEASE_FLAGS[@]}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 682319a..dd60ca9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ the default/static GitHub branch. ## Releases -Releases are deliberate, not automatic. `scripts/release.sh` bumps the version, updates `CHANGELOG.md`, commits, tags, and pushes; CI takes over from the tag push and publishes to PyPI. See `.claude/skills/release/SKILL.md` for the full flow. +Releases are deliberate, not automatic. `scripts/release.sh` bumps the version, updates `CHANGELOG.md`, commits, tags, and pushes; CI takes over from the tag push and publishes to PyPI. Release candidates are cut from the active `*-dev` branch (for example, `v0.19.0-rc.1` from `v0.19-dev`), while final versions are cut from the matching stable branch. See `.claude/skills/release/SKILL.md` for the full flow. **Semver-zero rule**: this package is on a 0.x line, so the MINOR component is the breaking-change boundary. `0.18 → 0.19` is a major bump and needs a new branch — the script refuses `minor`/`major` keywords without `--allow-major`. @@ -162,7 +162,7 @@ The project uses automated semantic versioning based on commit messages: | `feat!:`, `fix!:`, or `BREAKING CHANGE:` | **Major** version bump | 1.0.0 → 2.0.0 | | `docs:`, `style:`, `refactor:`, `test:`, `chore:`, `build:`, `ci:` | **No** version bump | Version stays the same | -**Important**: Never manually edit version numbers in `pyproject.toml` or other files. Releases are cut from the stable branch using the release automation described above. +**Important**: Never manually edit version numbers in `pyproject.toml` or other files. Final releases are cut from the stable branch and release candidates from its matching `*-dev` branch using the release automation described above. ## Logging Configuration diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 0125486..0b5b652 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -41,8 +41,11 @@ When an integration train is ready, open a promotion PR from the integration branch to the matching stable branch, for example `v0.19-dev` to `v0.19`. That promotion PR is the release-readiness gate and must pass required -cross-repo `E2E Tests`. The actual package release is cut from the stable branch -using a version tag after the stable branch is ready. +cross-repo `E2E Tests`. The final package release is cut from the stable branch +using a version tag after the stable branch is ready. A release candidate may +be cut earlier from the matching integration branch; RC tags use +`vX.Y.Z-rc.N`, publish as PyPI prereleases, and never substitute for the +promotion PR's final release gate. ## `main` diff --git a/releaserc.toml b/releaserc.toml index 612b6e5..eae5145 100644 --- a/releaserc.toml +++ b/releaserc.toml @@ -10,11 +10,16 @@ allow_zero_version = true no_git_verify = false tag_format = "v{version}" -[semantic_release.branches.main] -match = "main" +[semantic_release.branches.stable] +match = "^v[0-9]+\\.[0-9]+$" prerelease_token = "rc" prerelease = false +[semantic_release.branches.dev] +match = "^v[0-9]+\\.[0-9]+-dev$" +prerelease_token = "rc" +prerelease = true + [semantic_release.changelog] exclude_commit_patterns = [] mode = "update" @@ -61,4 +66,4 @@ insecure = false [semantic_release.publish] dist_glob_patterns = ["dist/*"] -upload_to_vcs_release = true \ No newline at end of file +upload_to_vcs_release = true diff --git a/scripts/release.sh b/scripts/release.sh index becd986..26fb4c1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Cut a release on the current stable branch. +# Cut a release on the current owning version branch. # # Bumps pyproject.toml, updates CHANGELOG.md via python-semantic-release, # commits, tags vX.Y.Z, and pushes both the branch commit and the tag. @@ -11,11 +11,13 @@ # you want to ship a release on (e.g. v0.18 for v0.18.x). # # Usage: -# scripts/release.sh # explicit semver — recommended +# scripts/release.sh # final release from vX.Y +# scripts/release.sh # release candidate from vX.Y-dev # scripts/release.sh patch # 0.18.0 → 0.18.1 # scripts/release.sh minor # 0.18.0 → 0.19.0 — refused unless --allow-major (see below) # scripts/release.sh major # 0.18.0 → 1.0.0 — refused unless --allow-major # scripts/release.sh --allow-major +# scripts/release.sh --dry-run [--allow-major] # # Semver-zero rule: while the major is 0, the MINOR is the breaking- # change boundary (per semver). 0.18 → 0.19 IS a major bump. The script @@ -23,7 +25,7 @@ # the current major is 0. Patches stay automatic-friendly. # # Pre-flight (each check refuses to proceed on failure): -# - On a v[.] branch (refuses on main / feature branches) +# - On vX.Y for a final or vX.Y-dev for a release candidate # - Working tree clean # - Local HEAD matches origin/ # - Latest CI run on HEAD is green @@ -31,14 +33,19 @@ set -euo pipefail ALLOW_MAJOR=0 -if [ "${1:-}" = "--allow-major" ]; then - ALLOW_MAJOR=1 +DRY_RUN=0 +while [[ "${1:-}" == --* ]]; do + case "$1" in + --allow-major) ALLOW_MAJOR=1 ;; + --dry-run) DRY_RUN=1 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac shift -fi +done VERSION_ARG="${1:-}" if [ -z "$VERSION_ARG" ]; then - echo "Usage: $0 [--allow-major] |patch|minor|major" >&2 + echo "Usage: $0 [--dry-run] [--allow-major] ||patch|minor|major" >&2 exit 2 fi @@ -46,12 +53,12 @@ repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" branch="$(git rev-parse --abbrev-ref HEAD)" -if ! [[ "$branch" =~ ^v[0-9]+(\.[0-9]+)?(-dev)?$ ]]; then +if ! [[ "$branch" =~ ^v[0-9]+\.[0-9]+(-dev)?$ ]]; then cat >&2 </dev/null 2>&1; then - status="$(gh run list --branch "$branch" --commit "$local_sha" --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null || echo "")" - case "$status" in - success) ;; - "" ) - echo "Warning: no CI run found for $local_sha on $branch. Continuing anyway." >&2 - ;; - *) - echo "Latest CI on $branch@$local_sha is '$status' (not success). Refusing to release a red commit." >&2 - exit 1 - ;; - esac +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required to verify the release head's native tests." >&2 + exit 1 fi +status="$(gh run list --workflow tests.yml --branch "$branch" --commit "$local_sha" --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null || echo "")" +case "$status" in + success) ;; + "" ) + echo "No Tests workflow run found for $local_sha on $branch. Refusing to release an unverified head." >&2 + exit 1 + ;; + *) + echo "Latest Tests workflow on $branch@$local_sha is '$status' (not success). Refusing to release a red commit." >&2 + exit 1 + ;; +esac -current_version="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" - -# Resolve to a concrete X.Y.Z so the major-bump guard can compare. +release_flags=() case "$VERSION_ARG" in major|minor|patch) - next_version="$(python3 - "$current_version" "$VERSION_ARG" <<'PY' -import sys -cur = sys.argv[1].split(".") -kind = sys.argv[2] -major, minor, patch = int(cur[0]), int(cur[1]), int(cur[2]) -if kind == "major": - print(f"{major+1}.0.0") -elif kind == "minor": - print(f"{major}.{minor+1}.0") -elif kind == "patch": - print(f"{major}.{minor}.{patch+1}") -PY -)" + release_flags+=("--$VERSION_ARG") ;; *) - next_version="$VERSION_ARG" + requested_version="$(python3 scripts/release_version.py normalize "$VERSION_ARG")" || exit 2 ;; esac -if ! [[ "$next_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Not a valid semver: $next_version" >&2 - exit 2 +if [[ "$branch" == *-dev ]]; then + release_flags+=(--as-prerelease --prerelease-token rc) fi -cur_major="${current_version%%.*}" +computed_raw="$( + uvx --from 'python-semantic-release==10.0.2' \ + semantic-release -c releaserc.toml version --print "${release_flags[@]}" +)" +next_version="$(python3 scripts/release_version.py normalize "$computed_raw")" || exit 2 + +if [[ -n "${requested_version:-}" && "$requested_version" != "$next_version" ]]; then + cat >&2 </dev/null + +last_tag="$(git describe --tags --abbrev=0 --match 'v*.*.*' 2>/dev/null || true)" +if [[ -z "$last_tag" ]]; then + echo "No previous release tag is reachable from $branch; refusing to infer release boundaries." >&2 + exit 1 +fi +last_version="$(python3 scripts/release_version.py normalize "$last_tag")" || exit 2 +cur_major="${last_version%%.*}" next_major="${next_version%%.*}" -cur_minor="$(echo "$current_version" | cut -d. -f2)" +cur_minor="$(echo "$last_version" | cut -d. -f2)" next_minor="$(echo "$next_version" | cut -d. -f2)" # Semver-zero: while major == 0, MINOR bumps are major bumps. @@ -130,7 +147,7 @@ if [ "$cur_major" = "0" ]; then if [ "$next_major" != "0" ] || [ "$next_minor" != "$cur_minor" ]; then if [ "$ALLOW_MAJOR" -ne 1 ]; then cat >&2 <&2 </dev/null +if ! git rev-parse --verify --quiet "refs/tags/v$next_version" >/dev/null; then + echo "Release tool did not create the expected tag v$next_version; refusing to push." >&2 + exit 1 +fi + # semantic-release commits and tags locally; we push explicitly so the # behaviour matches the JS-side script and the order of operations is # obvious from this file. diff --git a/scripts/release_version.py b/scripts/release_version.py new file mode 100644 index 0000000..08b4280 --- /dev/null +++ b/scripts/release_version.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Validate GenLayer Python release versions and their owning branches.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +import sys + + +_VERSION_PATTERN = re.compile( + r"^v?(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r"(?:(?:-?rc\.?(?P[1-9][0-9]*)))?$", + re.IGNORECASE, +) +_BRANCH_PATTERN = re.compile( + r"^v(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)(?P-dev)?$" +) + + +@dataclass(frozen=True) +class ReleaseVersion: + major: int + minor: int + patch: int + rc: int | None = None + + @property + def normalized(self) -> str: + suffix = "" if self.rc is None else f"-rc.{self.rc}" + return f"{self.major}.{self.minor}.{self.patch}{suffix}" + + @property + def is_prerelease(self) -> bool: + return self.rc is not None + + @property + def release_branch(self) -> str: + suffix = "-dev" if self.is_prerelease else "" + return f"v{self.major}.{self.minor}{suffix}" + + +def parse_release_version(value: str) -> ReleaseVersion: + match = _VERSION_PATTERN.fullmatch(value.strip()) + if match is None: + raise ValueError( + f"{value!r} is not a supported release version; use X.Y.Z for a " + "final release or X.Y.Z-rc.N for a release candidate" + ) + return ReleaseVersion( + major=int(match.group("major")), + minor=int(match.group("minor")), + patch=int(match.group("patch")), + rc=int(match.group("rc")) if match.group("rc") is not None else None, + ) + + +def validate_branch_version(branch: str, value: str) -> ReleaseVersion: + branch_match = _BRANCH_PATTERN.fullmatch(branch) + if branch_match is None: + raise ValueError( + f"{branch!r} is not a release branch; use vX.Y or vX.Y-dev" + ) + + version = parse_release_version(value) + branch_line = (int(branch_match.group("major")), int(branch_match.group("minor"))) + if branch_line != (version.major, version.minor): + raise ValueError( + f"{version.normalized} belongs to v{version.major}.{version.minor}, " + f"not {branch}" + ) + + branch_is_dev = branch_match.group("dev") is not None + if branch_is_dev and not version.is_prerelease: + raise ValueError(f"final release {version.normalized} must be cut from v{version.major}.{version.minor}") + if not branch_is_dev and version.is_prerelease: + raise ValueError( + f"release candidate {version.normalized} must be cut from " + f"v{version.major}.{version.minor}-dev" + ) + return version + + +def _usage() -> str: + return ( + "usage: release_version.py normalize | branch | " + "is-prerelease | validate | " + "verify-tag " + ) + + +def main(argv: list[str]) -> int: + try: + command = argv[1] + if command == "normalize" and len(argv) == 3: + print(parse_release_version(argv[2]).normalized) + elif command == "branch" and len(argv) == 3: + print(parse_release_version(argv[2]).release_branch) + elif command == "is-prerelease" and len(argv) == 3: + print("true" if parse_release_version(argv[2]).is_prerelease else "false") + elif command == "validate" and len(argv) == 4: + print(validate_branch_version(argv[2], argv[3]).normalized) + elif command == "verify-tag" and len(argv) == 4: + tag_version = parse_release_version(argv[2]) + package_version = parse_release_version(argv[3]) + if tag_version != package_version: + raise ValueError( + f"tag {tag_version.normalized} does not match package " + f"version {package_version.normalized}" + ) + print(tag_version.normalized) + else: + raise ValueError(_usage()) + except (IndexError, ValueError) as exc: + print(exc, file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/unit/test_release_version.py b/tests/unit/test_release_version.py new file mode 100644 index 0000000..6d3d273 --- /dev/null +++ b/tests/unit/test_release_version.py @@ -0,0 +1,63 @@ +import importlib.util +from pathlib import Path +import sys + +import pytest + + +MODULE_PATH = Path(__file__).parents[2] / "scripts" / "release_version.py" +SPEC = importlib.util.spec_from_file_location("release_version", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +release_version = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release_version +SPEC.loader.exec_module(release_version) + + +@pytest.mark.parametrize( + ("raw", "normalized", "branch", "is_prerelease"), + [ + ("0.19.0", "0.19.0", "v0.19", False), + ("v0.19.0-rc.1", "0.19.0-rc.1", "v0.19-dev", True), + ("0.19.0rc2", "0.19.0-rc.2", "v0.19-dev", True), + ], +) +def test_release_version_normalizes_pep440_rc_spellings( + raw, normalized, branch, is_prerelease +): + version = release_version.parse_release_version(raw) + + assert version.normalized == normalized + assert version.release_branch == branch + assert version.is_prerelease is is_prerelease + + +@pytest.mark.parametrize( + ("branch", "version", "message"), + [ + ("main", "0.19.0", "not a release branch"), + ("v0.18-dev", "0.19.0-rc.1", "belongs to v0.19"), + ("v0.19", "0.19.0-rc.1", "must be cut from v0.19-dev"), + ("v0.19-dev", "0.19.0", "must be cut from v0.19"), + ], +) +def test_release_version_rejects_wrong_release_route(branch, version, message): + with pytest.raises(ValueError, match=message): + release_version.validate_branch_version(branch, version) + + +def test_release_version_accepts_rc_only_on_owning_dev_line(): + version = release_version.validate_branch_version("v0.19-dev", "0.19.0rc1") + + assert version.normalized == "0.19.0-rc.1" + + +@pytest.mark.parametrize("version", ["0.19.0-alpha.1", "0.19.0-rc.0", "00.19.0"]) +def test_release_version_rejects_non_rc_or_noncanonical_versions(version): + with pytest.raises(ValueError, match="not a supported release version"): + release_version.parse_release_version(version) + + +def test_release_tag_and_package_version_compare_after_normalization(): + assert release_version.main( + ["release_version.py", "verify-tag", "v0.19.0-rc.1", "0.19.0rc1"] + ) == 0 From 3310320e32e1770291532120312265b50b9eb7f2 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 3 Sep 2026 00:57:12 +0100 Subject: [PATCH 3/3] docs: refresh v0.19 SDK reference --- README.md | 12 ++++++++++++ docs/api-references/api.md | 20 ++++++-------------- docs/api-references/index.md | 12 ++++++++++++ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1751b16..913e3c4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,18 @@ SDK releases follow their corresponding GenLayer protocol release. This release targets the current resolution-kernel train; use the matching older SDK release when connecting to an older deployment. +Use the dedicated preview preset for the release-candidate Studio deployment: + +```python +from genlayer_py import create_client +from genlayer_py.chains import studio_devnet + +client = create_client(chain=studio_devnet) +``` + +`studio_devnet` targets `https://studio-dev.genlayer.com/api` (chain ID 61997). +The existing `studionet` preset remains pinned to the stable hosted Studio. + Here’s how to initialize the client and connect to the GenLayer Simulator: ### Reading a Transaction diff --git a/docs/api-references/api.md b/docs/api-references/api.md index 6c8844e..4c78224 100644 --- a/docs/api-references/api.md +++ b/docs/api-references/api.md @@ -185,9 +185,8 @@ client.get_contract_schema_for_code(contract_code: AnyStr) Appeals a consensus transaction to trigger a new round of validation. Returns the original transaction_id (appeals operate on the same tx). -Deployed Consensus fills missing decision/value inputs from its -authoritative quote. Current Studio requires an explicit value and does -not accept ``expected_decision_id``. +Missing decision/value inputs are filled from the authoritative quote +on both Studio and deployed Consensus. ```python client.appeal_transaction(transaction_id: HexStr, account: Optional = None, value: Optional = None, expected_decision_id: Optional = None) @@ -225,9 +224,8 @@ client.top_up_fees(transaction_id: HexStr, distribution: FeesDistributionInput, Deposits appeal funding and submits an appeal. -On deployed Consensus, omitted decision/value inputs are resolved from -the authoritative appeal quote. Current Studio requires an explicit -value and does not accept ``expected_decision_id``. +Omitted decision/value inputs are resolved from the authoritative +appeal quote on both Studio and deployed Consensus. ```python client.top_up_and_submit_appeal(transaction_id: HexStr, distribution: FeesDistributionInput, account: Optional = None, value: Optional = None, expected_decision_id: Optional = None) @@ -247,9 +245,7 @@ client.top_up_and_submit_appeal(transaction_id: HexStr, distribution: FeesDistri ### can_appeal -Checks whether the exact active decision can be appealed on a network. - -This decision-bound read is not available on current Studio. +Checks whether the exact active decision can be appealed. ```python client.can_appeal(transaction_id: HexStr, expected_decision_id: Optional = None) @@ -266,9 +262,7 @@ client.can_appeal(transaction_id: HexStr, expected_decision_id: Optional = None) ### get_appeal_quote -Returns a network's latest decision id, appeal charges, and deadline. - -Current Studio has no decision-bound quote surface. +Returns the latest decision id, appeal charges, and deadline. ```python client.get_appeal_quote(transaction_id: HexStr) @@ -286,8 +280,6 @@ client.get_appeal_quote(transaction_id: HexStr) Returns the full appeal payment (bond plus induced-work funding). -Current Studio has no decision-bound quote surface. - ```python client.get_appeal_charge(transaction_id: HexStr) ``` diff --git a/docs/api-references/index.md b/docs/api-references/index.md index 0eba21a..0e6124a 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -24,6 +24,18 @@ SDK releases follow their corresponding GenLayer protocol release. This release targets the current resolution-kernel train; use the matching older SDK release when connecting to an older deployment. +Use the dedicated preview preset for the release-candidate Studio deployment: + +```python +from genlayer_py import create_client +from genlayer_py.chains import studio_devnet + +client = create_client(chain=studio_devnet) +``` + +`studio_devnet` targets `https://studio-dev.genlayer.com/api` (chain ID 61997). +The existing `studionet` preset remains pinned to the stable hosted Studio. + Here’s how to initialize the client and connect to the GenLayer Simulator: ### Reading a Transaction