From 4523bb5a860907c97f4c9c5e7e916af86b1b879a Mon Sep 17 00:00:00 2001 From: Jamie Hill-Daniel Date: Tue, 19 Aug 2025 12:53:08 +0100 Subject: [PATCH] cli: Add {pre,post}-{build,test,deploy} hooks --- CHANGELOG.md | 1 + cli/src/config.rs | 76 +++++++++++++++++++- cli/src/lib.rs | 15 +++- docs/content/docs/references/anchor-toml.mdx | 20 ++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e32f1d7535..bf265d9c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ The minor version will be incremented upon a breaking change and the patch versi - lang: Add support for tuple types in space calculation ([#3744](https://github.com/solana-foundation/anchor/pull/3744)). - lang: Add missing pubkey const generation ([#3677](https://github.com/solana-foundation/anchor/pull/3677)). - cli: Add the Minimum Supported Rust Version (MSRV) to the Rust template, since an arbitrary compiler version isn't supported ([#3873](https://github.com/solana-foundation/anchor/pull/3873)). +- cli: Add `hooks` section to `Anchor.toml` ([#3862](https://github.com/solana-foundation/anchor/pull/3862)). ### Fixes diff --git a/cli/src/config.rs b/cli/src/config.rs index e9b2e760c2..542dcd4e07 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -1,7 +1,7 @@ use crate::{get_keypair, is_hidden, keys_sync, DEFAULT_RPC_PORT}; use anchor_client::Cluster; use anchor_lang_idl::types::Idl; -use anyhow::{anyhow, Context, Error, Result}; +use anyhow::{anyhow, bail, Context, Error, Result}; use clap::{Parser, ValueEnum}; use dirs::home_dir; use heck::ToSnakeCase; @@ -21,6 +21,7 @@ use std::marker::PhantomData; use std::ops::Deref; use std::path::Path; use std::path::PathBuf; +use std::process::Command; use std::str::FromStr; use std::{fmt, io}; use walkdir::WalkDir; @@ -291,6 +292,7 @@ pub struct Config { pub provider: ProviderConfig, pub programs: ProgramsConfig, pub scripts: ScriptsConfig, + pub hooks: HooksConfig, pub workspace: WorkspaceConfig, // Separate entry next to test_config because // "anchor localnet" only has access to the Anchor.toml, @@ -384,6 +386,49 @@ pub type ScriptsConfig = BTreeMap; pub type ProgramsConfig = BTreeMap>; +#[derive(Default, Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HooksConfig { + #[serde(alias = "pre-build")] + pre_build: Option, + #[serde(alias = "post-build")] + post_build: Option, + #[serde(alias = "pre-test")] + pre_test: Option, + #[serde(alias = "post-test")] + post_test: Option, + #[serde(alias = "pre-deploy")] + pre_deploy: Option, + #[serde(alias = "post-deploy")] + post_deploy: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum Hook { + Single(String), + List(Vec), +} + +impl Hook { + pub fn hooks(&self) -> &[String] { + match self { + Self::Single(h) => std::slice::from_ref(h), + Self::List(l) => l.as_slice(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HookType { + PreBuild, + PostBuild, + PreTest, + PostTest, + PreDeploy, + PostDeploy, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct WorkspaceConfig { #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -501,6 +546,32 @@ impl Config { pub fn wallet_kp(&self) -> Result { get_keypair(&self.provider.wallet.to_string()) } + + pub fn run_hooks(&self, hook_type: HookType) -> Result<()> { + let hooks = match hook_type { + HookType::PreBuild => &self.hooks.pre_build, + HookType::PostBuild => &self.hooks.post_build, + HookType::PreTest => &self.hooks.pre_test, + HookType::PostTest => &self.hooks.post_test, + HookType::PreDeploy => &self.hooks.pre_deploy, + HookType::PostDeploy => &self.hooks.post_deploy, + }; + let cmds = hooks.as_ref().map(Hook::hooks).unwrap_or_default(); + for cmd in cmds { + let status = Command::new("bash") + .arg("-c") + .arg(cmd) + .status() + .with_context(|| format!("failed to execute `{cmd}`"))?; + if !status.success() { + match status.code() { + Some(code) => bail!("`{cmd}` failed with exit code {code}"), + None => bail!("`{cmd}` killed by signal"), + } + } + } + Ok(()) + } } #[derive(Debug, Serialize, Deserialize)] @@ -512,6 +583,7 @@ struct _Config { provider: Provider, workspace: Option, scripts: Option, + hooks: Option, test: Option<_TestValidator>, } @@ -610,6 +682,7 @@ impl fmt::Display for Config { true => None, false => Some(self.scripts.clone()), }, + hooks: Some(self.hooks.clone()), programs, workspace: (!self.workspace.members.is_empty() || !self.workspace.exclude.is_empty()) .then(|| self.workspace.clone()), @@ -635,6 +708,7 @@ impl FromStr for Config { wallet: shellexpand::tilde(&cfg.provider.wallet).parse()?, }, scripts: cfg.scripts.unwrap_or_default(), + hooks: cfg.hooks.unwrap_or_default(), test_validator: cfg.test.map(Into::into), test_config: None, programs: cfg.programs.map_or(Ok(BTreeMap::new()), deser_programs)?, diff --git a/cli/src/lib.rs b/cli/src/lib.rs index b8ad661b77..225ff75e23 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,7 +1,7 @@ use crate::config::{ - get_default_ledger_path, BootstrapMode, BuildConfig, Config, ConfigOverride, Manifest, - PackageManager, ProgramArch, ProgramDeployment, ProgramWorkspace, ScriptsConfig, TestValidator, - WithPath, SHUTDOWN_WAIT, STARTUP_WAIT, + get_default_ledger_path, BootstrapMode, BuildConfig, Config, ConfigOverride, HookType, + Manifest, PackageManager, ProgramArch, ProgramDeployment, ProgramWorkspace, ScriptsConfig, + TestValidator, WithPath, SHUTDOWN_WAIT, STARTUP_WAIT, }; use anchor_client::Cluster; use anchor_lang::idl::{IdlAccount, IdlInstruction, ERASED_AUTHORITY}; @@ -1333,6 +1333,8 @@ pub fn build( fs::create_dir_all(cfg_parent.join(&cfg.workspace.types))?; }; + cfg.run_hooks(HookType::PreBuild)?; + let cargo = Manifest::discover()?; let build_config = BuildConfig { verifiable, @@ -1390,6 +1392,7 @@ pub fn build( &arch, )?, } + cfg.run_hooks(HookType::PostBuild)?; set_workspace_dir_or_exit(); @@ -2989,6 +2992,9 @@ fn test( if (!is_localnet || skip_local_validator) && !skip_deploy { deploy(cfg_override, None, None, false, true, vec![])?; } + + cfg.run_hooks(HookType::PreTest)?; + let mut is_first_suite = true; if let Some(test_script) = cfg.scripts.get_mut("test") { is_first_suite = false; @@ -3057,6 +3063,7 @@ fn test( )?; } } + cfg.run_hooks(HookType::PostTest)?; Ok(()) }) } @@ -3566,6 +3573,7 @@ fn deploy( let client = create_client(&url); let solana_args = add_recommended_deployment_solana_args(&client, solana_args)?; + cfg.run_hooks(HookType::PreDeploy)?; // Deploy the programs. println!("Deploying cluster: {url}"); println!("Upgrade authority: {keypair}"); @@ -3676,6 +3684,7 @@ fn deploy( } println!("Deploy success"); + cfg.run_hooks(HookType::PostDeploy)?; Ok(()) }) diff --git a/docs/content/docs/references/anchor-toml.mdx b/docs/content/docs/references/anchor-toml.mdx index 8f5b89476c..a978088732 100644 --- a/docs/content/docs/references/anchor-toml.mdx +++ b/docs/content/docs/references/anchor-toml.mdx @@ -238,3 +238,23 @@ Example: [toolchain] package_manager = "pnpm" ``` + +### hooks + +The `hooks` table allows you to configure commands that may be run at specific stages of the build/test/deploy pipeline. + +Example: +```toml +[hooks] +# Accepts kebab-case names... +pre-build = "echo foo" +# ...and snake-case names +post_build = "echo bar" +# Accepts a list of commands, run in series +pre-test = ["echo 1", "echo 2"] +# Non-zero exit codes will abort the CLI +post-test = "exit 1" +# Unused hooks may be omitted +# pre-deploy = [] +# post-deploy = [] +```