Solex (SOlana EXpress) ships one Solana program to several release channels. A channel is a deployment: its own program id, cluster and upgrade authority, declared once in Solex.toml.
A feature is a Cargo feature of the program whose enablement solex decides per channel. Declare it once in the manifest with the channels that ship it, gate code with plain #[cfg(feature = "...")], and solex build --canary compiles exactly what canary ships. Promoting a feature to stable is a one-line manifest edit; the source does not change.
Agents produce code faster than anyone can review a big release. Solex keeps every upgrade small enough to test, review and audit as one diff, and the stable artifact carries nothing dormant.
Status: 0.2.1 on crates.io. Anchor 1.0 and Pinocchio programs build end to end with
cargo build-sbf; see solana-foundation/payment-channels#96 for a real program on two channels. Expect the API to move before 1.0.
stable, canary, canary, or whatever your team calls them. Each channel lists its program id, cluster(s), keypair and the channels it includes. Write it as Solex.toml, Solex.yaml, or a [solex] section inside Anchor.toml or Quasar.toml.
[features.hooks] channels = ["canary"] in the manifest, hooks = [] in Cargo.toml, #[cfg(feature = "hooks")] in the code. solex build --stable passes no --features hooks, so the stable binary has no dormant hooks: no discriminator, no IDL entry, no bytes.
[features.hooks] epoch = { stable = 850 } plus #[solex::activation("hooks")] on the handler: compiled in, but FEATURE_NOT_ACTIVE until the cluster reaches epoch 850, the way Solana's own feature gates activate.
solex features reads the manifest and the sources and prints one entry per feature with the channels it ships on. Reviewers and auditors see the same view the compiler acts on, and --check fails on a feature missing from Cargo.toml, declared but unused, or a build script that would let cargo reuse another channel's compilation.
Treasury owners, admin keys and fee accounts live next to the program id as vars on each channel and are read with solex::var!("treasury_owner"). A channel without the var does not compile, so a placeholder can never reach a real cluster.
solex build, solex deploy and solex terminate take the channel's program id, cluster, authority and features from the manifest. Other pipelines get the same selection through solex env and solex exec.
From crates.io
cargo install solexFrom source
git clone https://github.com/lgalabru/solex
cd solex
just install # cargo install --path crates/cli --forceRequires the Solana CLI with cargo build-sbf for program builds. Programs depend on solex-lang; solex setup installs the agent skill and registers the MCP server with Claude Code and Codex.
Scaffold an Anchor program with two channels and a keypair for each:
solex init counter --cluster stable=mainnet --cluster canary=devnet
cd counterSee the channels and what each one ships:
$ solex channels
┌────────────────────────────────────────────────────────────────────────────┐
│ stable mainnet default │
│ program 2mwK…UsqH │
├────────────────────────────────────────────────────────────────────────────┤
│ canary devnet │
│ program 9rws…KGMs │
│ includes stable │
│ features reset (from epoch 1000) │
└────────────────────────────────────────────────────────────────────────────┘
$ solex features
┌────────────────────────────────────────────────────────────────────────────┐
│ #[channel(stable)] stable canary 2 gates │
├────────────────────────────────────────────────────────────────────────────┤
│ reset canary from epoch 1000 1 gate │
│ resets the counter; canary-only until proven │
└────────────────────────────────────────────────────────────────────────────┘Build and deploy a channel. Any declared channel name works as a flag:
$ solex build --canary
┌────────────────────────────────────────────────────────────────────────────┐
│ canary devnet 98.9 KiB 761ms │
│ program 9rws…KGMs │
│ features reset │
│ artifact target/solex/canary/counter.so │
└────────────────────────────────────────────────────────────────────────────┘
$ solex deploy --canary
✔ Deploy canary to (space toggles, enter confirms) · devnet 9rws…KGMs
┌────────────────────────────────────────────────────────────────────────────┐
│ canary devnet │
│ program 9rws…KGMs │
│ features reset │
│ artifact target/solex/canary/counter.so │
└────────────────────────────────────────────────────────────────────────────┘
│ Deployed canary to devnet
│ 9rwsA9m17bLqtT1GZcjGZiWfzm4yXsu1p6w7AiAPKGMs ↗ [⌘ + click] to open Explorerdeploy asks which of the channel's clusters to send to; -y skips the question in CI.
| Command | Purpose |
|---|---|
solex init <name> |
scaffold a project; --channels a,b,c, --cluster ch=cluster, --format toml |
solex channels |
deployments, program ids, clusters, includes, features, vars |
solex features [name] [--items] [--json] [--check] |
one entry per feature; name lists that feature's markers with file:line |
solex build [--<channel> | --all] [--cluster c] |
cargo build-sbf with the channel's features into target/solex/<channel>/ |
solex deploy [--<channel>] [--build] [--dry-run] [-y] [-- <args>] |
solana program deploy with the channel's id, cluster and authority; asks which cluster(s), -y skips; -- <args> go to the Solana CLI |
solex metadata [--<channel>] [--cluster c] [--seed s] [--dry-run] [-y] |
publish [metadata.<seed>] (IDL, ...) to the Program Metadata program; skips unchanged content, resumes interrupted writes; --close-buffers reclaims leftovers; deploy --metadata runs it after a deploy |
solex terminate [--<channel>] [--buffers] --yes |
solana program close; reclaims rent; mainnet needs --allow-mainnet |
solex test [--<channel>] [-- args] |
cargo test with the channel selected and its features enabled |
solex env [--format shell|dotenv|json] |
SOLEX_CHANNEL, SOLEX_MANIFEST, SOLEX_CLUSTER, SOLEX_FEATURES for other pipelines |
solex exec [--<channel>] -- <cmd> |
run any command with the channel selected |
solex setup |
install the agent skill and register the MCP server with Claude Code and Codex; --no-skill, --no-mcp |
solex mcp |
MCP server: channels, features, builds, deploy plans |
Channel selection everywhere: --<channel> or --channel <name>, else SOLEX_CHANNEL, else default_channel in the manifest.
# Cargo.toml
[features]
hooks = []# Solex.toml
default_channel = "stable"
[channels.stable]
description = "V1 instruction set, the audited surface"
program_id = "CHNLxYvV…"
cluster = "mainnet"
vars = { treasury_owner = "Cs2zdf…" }
[channels.canary]
description = "stable plus the hooks feature"
includes = ["stable"]
program_id = "CHNLxBtX…"
cluster = "devnet"
vars = { treasury_owner = "4zTeC5…" }
[features.hooks]
description = "V2 lifecycle hooks"
channels = ["canary"] # add "stable" here to promote
epoch = { stable = 850 } # once on stable, inactive until epoch 850solex::declare_id!(); // program id of the channel being built
pub const TREASURY_OWNER: [u8; 32] = decode_pubkey(solex::var!("treasury_owner"));
#[cfg(feature = "hooks")]
OpenWithHooks(OpenArgs<'a>) = 10, // any item, variant, arm or field: plain cfg
#[cfg(feature = "hooks")]
#[solex::activation("hooks")] // runtime epoch gate where the manifest declares one
pub fn open_with_hooks(...) -> ProgramResult { ... }$ solex features
┌────────────────────────────────────────────────────────────────────────────┐
│ hooks canary 24 gates │
│ V2 lifecycle hooks │
└────────────────────────────────────────────────────────────────────────────┘Features are declared once, so there is nothing to forget across the 24 markers of a real instruction family. solex features --check also catches a feature in the manifest that Cargo.toml does not declare, one that no cfg uses, and a build.rs that does not list SOLEX_CHANNEL, SOLEX_MANIFEST and SOLEX_CLUSTER under rerun-if-env-changed, which would let cargo reuse another channel's compilation.
For code tied to a release train rather than a feature, #[channel(...)] gates directly on channel names, with the same includes resolution:
| Attribute | Meaning |
|---|---|
#[channel(canary)] |
compiled into channels that ship canary |
#[channel(stable, canary)] |
compiled if the channel ships any listed tag |
#[channel([canary, 850])] |
compiled into canary; inactive until epoch 850 |
Attribute macros cannot sit on enum variants or match arms, so for those #[solex::gated] on the enclosing item resolves nested #[channel] markers; #[solex::program] does this for Anchor modules. Cargo features need none of this.
cluster takes a list. Identical bytes go to every cluster unless a cluster overrides something, written as [channels.<channel>.<cluster>]:
[channels.stable]
program_id = "CHNLxYvV…"
cluster = ["mainnet", "devnet"]
vars = { treasury_owner = "Cs2zdf…" }
[channels.stable.devnet] # inherits everything else
program_id = "CHNLxBtX…"
vars = { treasury_owner = "4zTeC5…" }upgrade_authority is a keypair file, or the address of an authority whose key is not on this machine (a hardware wallet, a multisig vault). Before uploading anything, solex deploy reads the program's authority from the cluster: a declared keypair or address that is not the one on chain stops the deploy, and with nothing declared the chain's authority is used. When the authority is an ordinary key with no keypair here, solex deploy uploads the program to a buffer with the fee payer and stops there, leaving the one transaction the authority has to sign in target/solex/<channel>/upgrade.tx (base64, already signed by the fee payer). With Vow installed it also writes a .vow review file next to it for devnet and mainnet (vow on the PATH, in Vow.app, or SOLEX_VOW). The buffer stays under the fee payer until that transaction runs, so its rent can be reclaimed with solana program close. The transaction uses a recent blockhash and expires in about a minute, so today it serves review more than signing on another device; long-lived approvals are planned through the Programmatic Signer once it is released, not durable nonces. Sending the signed result, first deploys and program-derived authorities (multisig vaults) are not built yet.
Because var! and declare_id! resolve at compile time, a channel with overrides is one artifact per cluster, target/solex/stable/mainnet/ and target/solex/stable/devnet/, each built with SOLEX_CLUSTER set; solex deploy sends each to its cluster and --cluster narrows any command to one. A build that cannot tell which cluster it is for fails rather than guessing. A channel without program_id is tag-only: it names a surface for other channels to include and is never built.
Each deployment can publish its IDL (or any other metadata) through the Program Metadata program. Solex talks to the program directly; no other CLI is involved.
[metadata.idl]
command = ["just", "channel-idl"] # optional; runs with SOLEX_CHANNEL, SOLEX_CLUSTER, SOLEX_FEATURES, SOLEX_PROGRAM_ID
file = "target/solex/{channel}/idl.json" # {channel} {cluster} {name} {lib} {out}
# format = "json" # default from the extensionsolex metadata --canary --cluster devnet # generate, check, compare, write
solex deploy --canary --metadata # the same, right after each deployBefore writing, solex refuses an IDL whose program address is not the channel's (Codama program.publicKey, Anchor address), and skips the write when the chain already holds the same content. The account is canonical metadata, signed by the channel's upgrade_authority (or --keypair, else the Solana CLI keypair). A write interrupted halfway resumes from the buffer it left at the metadata address; solex metadata --close-buffers lists and closes any other buffers of the authority. Public RPCs rate-limit uploads of a few dozen transactions: set SOLEX_RPC_DEVNET / SOLEX_RPC_MAINNET or pass --rpc.
Solex owns two things at build time: the environment the proc macros read (SOLEX_CHANNEL, SOLEX_MANIFEST, SOLEX_CLUSTER) and the --features list. solex build is a convenience over that:
[build]
# default pipeline: cargo build-sbf, with --manifest-path and --sbf-out-dir added
args = ["--tools-version", "v1.58", "--arch", "v3"][build]
# upstream rustc + sbpf-linker (github.com/blueshift-gg/sbpf-linker)
command = ["cargo", "+nightly", "build-bpf"]
artifact = "target/bpfel-unknown-none/release/lib{lib}.so" # copied to target/solex/<channel>/For a pipeline solex should not drive at all:
eval "$(solex env --canary)" # SOLEX_CHANNEL, SOLEX_MANIFEST, SOLEX_FEATURES=hooks
cargo +nightly build-bpf --features "$SOLEX_FEATURES"
solex exec --canary -- cargo test --features "$SOLEX_FEATURES"Verified builds work the same way: the container running solana-verify must see the same variables and features.
solex-lang defaults to Anchor: #[solex::program] applies channel gating, then hands the module to #[anchor_lang::program], and declare_id! wraps Anchor's. With default-features = false and features = ["pinocchio"] the crate is no_std, declare_id! wraps pinocchio::address::declare_id!, and the epoch gate reads pinocchio's Clock sysvar. features = ["native"] targets the modular solana-* crates.
The crates are split so a framework CLI can offer channels without shelling out to solex:
solex-typesis plain serde data. Deserialize thesolexsection of your own manifest intosolex_types::Channelsand you have channel resolution,includes, features per channel and activation epochs. Theyaml,tomlandspecfeatures are optional.solex-coretakes that section throughProject::from_partsand exposesbuild::target_envandbuild::channel_features, the environment and--featuresyour owncargo build-sbfcall needs, plus the scan, deploy and keypair logic.- The proc macros find the configuration by walking up from
CARGO_MANIFEST_DIR, or read the file named bySOLEX_MANIFEST. A host manifest with asolexkey is accepted as-is, so your users keep one config file.
| Crate | Purpose |
|---|---|
crates/types |
solex-types: the solex manifest section (channels, features, overrides), resolution rules, #[channel] grammar; YAML and TOML |
crates/macros |
proc macros: declare_id!, var!, activation, channel, gated, program |
crates/lang |
solex-lang, the crate programs depend on (use solex::...); Anchor, Pinocchio or native |
crates/core |
solex-core: build, deploy, terminate, scan and scaffold logic, reusable from other CLIs |
crates/cli |
the solex binary (cargo install solex) and its display components |
crates/mcp |
solex-mcp: thin MCP server over core (solex mcp) |
tests/native |
macro and feature tests on a framework-free program, run once per channel |
tests/pinocchio |
no_std compile check of the pinocchio feature with hand-rolled dispatch |
skills/solex |
agent skill (skills.sh compatible) on channels and gating; solex setup installs it |
examples/counter |
the program solex init produces |
just test # workspace tests, native tests per channel, pinocchio check
just example-check # type-check the Anchor example per channel
just example-build # cargo build-sbf of the example for every channelIssues and pull requests are welcome at github.com/lgalabru/solex.
MIT.