From 3988357f014ebf44168bc2f4c3afd6a768acebab Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 13 May 2026 11:47:47 -0400 Subject: [PATCH 1/5] feat(sync): expose ICP context as environment variables in sync scripts Sync script steps now receive ICP_CLI_ENVIRONMENT, ICP_CLI_NETWORK, ICP_CLI_CID (current canister), and ICP_CLI_CID_ (all project canisters) as environment variables on every executed command. Co-Authored-By: Claude Sonnet 4.6 --- crates/icp-cli/src/commands/deploy.rs | 9 +++++++++ crates/icp-cli/src/commands/sync.rs | 9 +++++++++ crates/icp-cli/src/operations/sync.rs | 11 +++++++++++ crates/icp/src/canister/sync/mod.rs | 6 ++++++ crates/icp/src/canister/sync/script.rs | 15 ++++++++++++++- 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index e20ccdb57..611fa3870 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -11,6 +11,7 @@ use icp::{ }; use icp_canister_interfaces::candid_ui::MAINNET_CANDID_UI_CID; use serde::Serialize; +use std::collections::BTreeMap; use tracing::info; use crate::{ @@ -362,12 +363,20 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: // method to permit the user identity to upload assets directly before syncing. info!("Syncing canisters:"); + let canister_ids: BTreeMap = ctx + .ids_by_environment(&environment_selection) + .await? + .into_iter() + .collect(); + let pkg_cache = ctx.dirs.package_cache()?; sync_many( ctx.syncer.clone(), agent.clone(), sync_canisters, environment_selection.name().to_owned(), + env.network.name.clone(), + canister_ids, args.proxy, ctx.debug, &pkg_cache, diff --git a/crates/icp-cli/src/commands/sync.rs b/crates/icp-cli/src/commands/sync.rs index 5f3c9fdd5..ee4360e74 100644 --- a/crates/icp-cli/src/commands/sync.rs +++ b/crates/icp-cli/src/commands/sync.rs @@ -3,6 +3,7 @@ use clap::Args; use futures::future::try_join_all; use icp::context::{CanisterSelection, Context, EnvironmentSelection}; use icp::identity::IdentitySelection; +use std::collections::BTreeMap; use tracing::info; use crate::{ @@ -82,12 +83,20 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E info!("Syncing canisters:"); + let canister_ids: BTreeMap = ctx + .ids_by_environment(&environment_selection) + .await? + .into_iter() + .collect(); + let pkg_cache = ctx.dirs.package_cache()?; sync_many( ctx.syncer.clone(), agent, sync_canisters, environment_selection.name().to_owned(), + env.network.name.clone(), + canister_ids, args.proxy, ctx.debug, &pkg_cache, diff --git a/crates/icp-cli/src/operations/sync.rs b/crates/icp-cli/src/operations/sync.rs index e0f6d4227..9431f7939 100644 --- a/crates/icp-cli/src/operations/sync.rs +++ b/crates/icp-cli/src/operations/sync.rs @@ -8,6 +8,7 @@ use icp::{ prelude::PathBuf, }; use snafu::prelude::*; +use std::collections::BTreeMap; use std::sync::Arc; use tracing::error; @@ -35,6 +36,8 @@ async fn sync_canister( canister_id: Principal, canister_info: &Canister, environment: &str, + network: &str, + canister_ids: &BTreeMap, proxy: Option, pb: &mut MultiStepProgressBar, pkg_cache: &PackageCache, @@ -56,6 +59,8 @@ async fn sync_canister( path: canister_path.clone(), cid: canister_id, environment: environment.to_owned(), + network: network.to_owned(), + canister_ids: canister_ids.clone(), proxy, }, agent, @@ -79,6 +84,8 @@ pub(crate) async fn sync_many( agent: Agent, canisters: Vec<(Principal, PathBuf, Canister)>, environment: String, + network: String, + canister_ids: BTreeMap, proxy: Option, debug: bool, pkg_cache: &PackageCache, @@ -93,6 +100,8 @@ pub(crate) async fn sync_many( let agent = agent.clone(); let syncer = syncer.clone(); let environment = environment.clone(); + let network = network.clone(); + let canister_ids = canister_ids.clone(); async move { // Define the sync logic @@ -103,6 +112,8 @@ pub(crate) async fn sync_many( cid, &canister_info, &environment, + &network, + &canister_ids, proxy, &mut pb, pkg_cache, diff --git a/crates/icp/src/canister/sync/mod.rs b/crates/icp/src/canister/sync/mod.rs index 10bb53fb6..480ff2bdb 100644 --- a/crates/icp/src/canister/sync/mod.rs +++ b/crates/icp/src/canister/sync/mod.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use async_trait::async_trait; use candid::Principal; use ic_agent::Agent; @@ -18,6 +20,10 @@ pub struct Params { /// Name of the environment being synced (e.g. "local", "production"). /// Passed to sync plugin steps via `SyncExecInput`. pub environment: String, + /// Name of the network (e.g. "local", "ic"). + pub network: String, + /// IDs of all named canisters in the project for this environment. + pub canister_ids: BTreeMap, /// Proxy canister to route calls through, if `--proxy` was passed. pub proxy: Option, } diff --git a/crates/icp/src/canister/sync/script.rs b/crates/icp/src/canister/sync/script.rs index 89fdfc73d..74d12647a 100644 --- a/crates/icp/src/canister/sync/script.rs +++ b/crates/icp/src/canister/sync/script.rs @@ -11,5 +11,18 @@ pub(super) async fn sync( params: &Params, stdio: Option>, ) -> Result<(), ScriptError> { - execute(adapter, params.path.as_ref(), &[], stdio).await + let mut envs: Vec<(String, String)> = vec![ + ("ICP_CLI_ENVIRONMENT".to_owned(), params.environment.clone()), + ("ICP_CLI_NETWORK".to_owned(), params.network.clone()), + ("ICP_CLI_CID".to_owned(), params.cid.to_text()), + ]; + for (name, id) in ¶ms.canister_ids { + let key = format!( + "ICP_CLI_CID_{}", + name.to_uppercase().replace(['-', '.', ' '], "_") + ); + envs.push((key, id.to_text())); + } + let env_refs: Vec<(&str, &str)> = envs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + execute(adapter, params.path.as_ref(), &env_refs, stdio).await } From c6ed8ceef50f1d070541246e2291859ad85d40e4 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 13 May 2026 11:53:28 -0400 Subject: [PATCH 2/5] test(sync): verify ICP env vars are set in sync script steps Adds three integration tests: - sync_script_icp_env_vars: single-canister sync verifies all four vars (ICP_CLI_ENVIRONMENT, ICP_CLI_NETWORK, ICP_CLI_CID, ICP_CLI_CID_) - sync_script_icp_cid_cross_canister: multi-canister sync verifies each canister's script can see other canisters' ICP_CLI_CID_ vars - deploy_sync_script_icp_env_vars: deploy-triggered sync path also receives the correct env and CID vars Co-Authored-By: Claude Sonnet 4.6 --- crates/icp-cli/tests/deploy_tests.rs | 87 ++++++++++++++++++++++++++++ crates/icp-cli/tests/sync_tests.rs | 83 ++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/crates/icp-cli/tests/deploy_tests.rs b/crates/icp-cli/tests/deploy_tests.rs index 7f8affd08..6ac9e3ba0 100644 --- a/crates/icp-cli/tests/deploy_tests.rs +++ b/crates/icp-cli/tests/deploy_tests.rs @@ -13,6 +13,7 @@ use crate::common::{ use icp::{ fs::{create_dir_all, write_string}, prelude::*, + store_id::IdMapping, }; mod common; @@ -1038,3 +1039,89 @@ async fn deploy_through_proxy() { .success() .stdout(contains("Status: Running").and(contains(&proxy_cid))); } + +#[tokio::test] +async fn deploy_sync_script_icp_env_vars() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + // canister-a verifies env/network vars during deploy; canister-b verifies cross-canister + // CID visibility during the explicit sync step. + let pm = formatdoc! {r#" + canisters: + - name: canister-a + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: script + command: echo "ENV=$ICP_CLI_ENVIRONMENT NET=$ICP_CLI_NETWORK CID=$ICP_CLI_CID B_CID=$ICP_CLI_CID_CANISTER_B" + - name: canister-b + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: script + command: echo "B_SEES_A=$ICP_CLI_CID_CANISTER_A" + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + clients::icp(&ctx, &project_dir, Some("random-environment".to_string())) + .mint_cycles(10 * TRILLION); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "--debug", + "deploy", + "--subnet", + common::SUBNET_ID, + "--environment", + "random-environment", + ]) + .assert() + .success() + .stderr(contains("ENV=random-environment")) + .stderr(contains("NET=random-network")); + + // Read the assigned canister IDs and verify CID vars and cross-canister visibility. + let id_mapping: IdMapping = icp::fs::json::load( + &project_dir + .join(".icp") + .join("cache") + .join("mappings") + .join("random-environment.ids.json"), + ) + .expect("failed to read ID mapping"); + + let cid_a = id_mapping + .get("canister-a") + .expect("canister-a ID not found") + .to_text(); + + let cid_b = id_mapping + .get("canister-b") + .expect("canister-b ID not found") + .to_text(); + + ctx.icp() + .current_dir(&project_dir) + .args(["--debug", "sync", "--environment", "random-environment"]) + .assert() + .success() + .stderr(contains(format!("CID={cid_a}"))) + .stderr(contains(format!("B_CID={cid_b}"))) + .stderr(contains(format!("B_SEES_A={cid_a}"))); +} diff --git a/crates/icp-cli/tests/sync_tests.rs b/crates/icp-cli/tests/sync_tests.rs index 69beff427..4d0c9efbb 100644 --- a/crates/icp-cli/tests/sync_tests.rs +++ b/crates/icp-cli/tests/sync_tests.rs @@ -540,6 +540,89 @@ async fn sync_plugin_registers_seed_data() { ); } +#[tokio::test] +async fn sync_script_icp_env_vars() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + // canister-a verifies all four env vars; canister-b verifies cross-canister CID visibility. + let pm = formatdoc! {r#" + canisters: + - name: canister-a + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: script + command: echo "ENV=$ICP_CLI_ENVIRONMENT NET=$ICP_CLI_NETWORK CID=$ICP_CLI_CID B_CID=$ICP_CLI_CID_CANISTER_B" + - name: canister-b + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: script + command: echo "B_SEES_A=$ICP_CLI_CID_CANISTER_A" + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + clients::icp(&ctx, &project_dir, Some("random-environment".to_string())) + .mint_cycles(10 * TRILLION); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--subnet", + common::SUBNET_ID, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + let id_mapping: IdMapping = icp::fs::json::load( + &project_dir + .join(".icp") + .join("cache") + .join("mappings") + .join("random-environment.ids.json"), + ) + .expect("failed to read ID mapping"); + + let cid_a = id_mapping + .get("canister-a") + .expect("canister-a ID not found") + .to_text(); + + let cid_b = id_mapping + .get("canister-b") + .expect("canister-b ID not found") + .to_text(); + + ctx.icp() + .current_dir(&project_dir) + .args(["--debug", "sync", "--environment", "random-environment"]) + .assert() + .success() + .stderr(contains("ENV=random-environment")) + .stderr(contains("NET=random-network")) + .stderr(contains(format!("CID={cid_a}"))) + .stderr(contains(format!("B_CID={cid_b}"))) + .stderr(contains(format!("B_SEES_A={cid_a}"))); +} + #[tokio::test] async fn sync_plugin_routes_through_proxy() { let ctx = TestContext::new(); From df9c39b43dbcdabfa4f8ceed6b34376dfd2c28cb Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 13 May 2026 13:16:20 -0400 Subject: [PATCH 3/5] docs(sync): document ICP_CLI_* environment variables for sync scripts Adds a Sync Script Variables section to the environment variables reference, and cross-references it from build-deploy-sync.md and creating-recipes.md, following the same pattern as ICP_WASM_OUTPUT_PATH. Co-Authored-By: Claude Sonnet 4.6 --- docs/concepts/build-deploy-sync.md | 23 +++++++++++++++ docs/guides/creating-recipes.md | 17 +++++++++++ docs/reference/environment-variables.md | 39 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/docs/concepts/build-deploy-sync.md b/docs/concepts/build-deploy-sync.md index 0b6b4868b..3b947ea2d 100644 --- a/docs/concepts/build-deploy-sync.md +++ b/docs/concepts/build-deploy-sync.md @@ -124,6 +124,29 @@ sync: dir: dist ``` +### Script Sync Steps + +You can also run arbitrary shell commands in sync steps: + +```yaml +sync: + steps: + - type: script + commands: + - my-tool upload --canister "$ICP_CLI_CID" --env "$ICP_CLI_ENVIRONMENT" +``` + +### Environment Variables + +Script sync steps have access to: + +- `ICP_CLI_ENVIRONMENT` — The current environment name (e.g. `local`, `staging`) +- `ICP_CLI_NETWORK` — The current network name (e.g. `local`, `ic`) +- `ICP_CLI_CID` — The canister ID of the canister being synced +- `ICP_CLI_CID_` — The canister ID of every canister in the project (name uppercased, `-`/`.`/` ` replaced with `_`) + +See [Environment Variables Reference](../reference/environment-variables.md#sync-script-variables) for full details. + ### When Sync Runs - Automatically after `icp deploy` diff --git a/docs/guides/creating-recipes.md b/docs/guides/creating-recipes.md index 6e3b63f14..7b2e18a4f 100644 --- a/docs/guides/creating-recipes.md +++ b/docs/guides/creating-recipes.md @@ -146,6 +146,23 @@ canisters: Use `{{#if}}` with `{{else}}` for defaults, refer to the examples above. +## Environment Variables + +Recipe scripts have access to runtime environment variables set by icp-cli. + +**Build script steps** receive: + +- `ICP_WASM_OUTPUT_PATH` — Where to write the compiled WASM file + +**Sync script steps** receive: + +- `ICP_CLI_ENVIRONMENT` — The current environment name (e.g. `local`, `staging`) +- `ICP_CLI_NETWORK` — The current network name (e.g. `local`, `ic`) +- `ICP_CLI_CID` — The canister ID of the canister being synced +- `ICP_CLI_CID_` — The canister ID of every canister in the project + +See [Environment Variables Reference](../reference/environment-variables.md) for full details. + ## Testing Recipes Test your recipe by viewing the expanded configuration: diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index e772d9d39..25e0629a2 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -27,6 +27,45 @@ build: The script also runs with the **canister directory as the current working directory**, so relative paths in your build commands resolve from there. +## Sync Script Variables + +During `script` sync steps, icp-cli sets the following environment variables: + +### `ICP_CLI_ENVIRONMENT` + +The name of the current environment (e.g. `local`, `staging`, `production`). + +### `ICP_CLI_NETWORK` + +The name of the current network (e.g. `local`, `ic`). + +### `ICP_CLI_CID` + +The canister ID (principal) of the canister being synced. + +### `ICP_CLI_CID_` + +The canister ID (principal) of every canister in the project, one variable per canister. `` is the canister name uppercased with `-`, `.`, and spaces replaced by `_`. + +For example, a project with canisters `backend` and `my-frontend` produces: + +``` +ICP_CLI_CID_BACKEND=bkyz2-fmaaa-aaaaa-qaaaq-cai +ICP_CLI_CID_MY_FRONTEND=bd3sg-teaaa-aaaaa-qaaba-cai +``` + +**Example:** +```yaml +sync: + steps: + - type: script + commands: + - echo "Syncing canister $ICP_CLI_CID on $ICP_CLI_NETWORK" + - my-tool upload --canister "$ICP_CLI_CID" --backend "$ICP_CLI_CID_BACKEND" +``` + +Like build scripts, sync scripts run with the **canister directory as the current working directory**. + ## CLI Configuration Variables ### `ICP_ENVIRONMENT` From 236f0839d5e1afdaf43509d6d3e0146c51c71989 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 13 May 2026 13:16:37 -0400 Subject: [PATCH 4/5] chore: add changelog entry for sync script ICP_CLI_* env vars Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a199d9ea4..a088bf538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +* feat: `script` sync steps now receive `ICP_CLI_ENVIRONMENT`, `ICP_CLI_NETWORK`, `ICP_CLI_CID` (the current canister's principal), and `ICP_CLI_CID_` (every canister's principal) as environment variables. * fix: `icp` no longer picks up a stale inherited `$PWD` when launched as a subprocess via `chdir(2)` + `execve` (e.g. from a test harness). The logical `$PWD` path is now validated against `getcwd()` by inode before use, preserving symlink-aware project root discovery while ignoring stale values. # v0.2.6 From 0660bdb78e1002c49a03d57ef6a00b0a6d533552 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 13 May 2026 13:53:11 -0400 Subject: [PATCH 5/5] fix(sync): sanitize all non-alphanumeric chars in ICP_CLI_CID_ keys and correct docs Normalization previously only handled `-`, `.`, and spaces; any other character (unicode, `@`, `/`, etc.) would produce an invalid POSIX env var name. Now any character outside `[A-Z0-9]` is replaced with `_`. Docs updated to reflect that ICP_CLI_CID_ is populated from the environment's ID mapping, not necessarily every canister in the project. Co-Authored-By: Claude Sonnet 4.6 --- crates/icp/src/canister/sync/script.rs | 5 ++++- docs/concepts/build-deploy-sync.md | 2 +- docs/guides/creating-recipes.md | 2 +- docs/reference/environment-variables.md | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/icp/src/canister/sync/script.rs b/crates/icp/src/canister/sync/script.rs index 74d12647a..1043e8777 100644 --- a/crates/icp/src/canister/sync/script.rs +++ b/crates/icp/src/canister/sync/script.rs @@ -19,7 +19,10 @@ pub(super) async fn sync( for (name, id) in ¶ms.canister_ids { let key = format!( "ICP_CLI_CID_{}", - name.to_uppercase().replace(['-', '.', ' '], "_") + name.to_uppercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::() ); envs.push((key, id.to_text())); } diff --git a/docs/concepts/build-deploy-sync.md b/docs/concepts/build-deploy-sync.md index 3b947ea2d..468d35a81 100644 --- a/docs/concepts/build-deploy-sync.md +++ b/docs/concepts/build-deploy-sync.md @@ -143,7 +143,7 @@ Script sync steps have access to: - `ICP_CLI_ENVIRONMENT` — The current environment name (e.g. `local`, `staging`) - `ICP_CLI_NETWORK` — The current network name (e.g. `local`, `ic`) - `ICP_CLI_CID` — The canister ID of the canister being synced -- `ICP_CLI_CID_` — The canister ID of every canister in the project (name uppercased, `-`/`.`/` ` replaced with `_`) +- `ICP_CLI_CID_` — The canister ID of every canister with a registered ID in the current environment (name uppercased, non-alphanumeric characters replaced with `_`) See [Environment Variables Reference](../reference/environment-variables.md#sync-script-variables) for full details. diff --git a/docs/guides/creating-recipes.md b/docs/guides/creating-recipes.md index 7b2e18a4f..d3a45e6a1 100644 --- a/docs/guides/creating-recipes.md +++ b/docs/guides/creating-recipes.md @@ -159,7 +159,7 @@ Recipe scripts have access to runtime environment variables set by icp-cli. - `ICP_CLI_ENVIRONMENT` — The current environment name (e.g. `local`, `staging`) - `ICP_CLI_NETWORK` — The current network name (e.g. `local`, `ic`) - `ICP_CLI_CID` — The canister ID of the canister being synced -- `ICP_CLI_CID_` — The canister ID of every canister in the project +- `ICP_CLI_CID_` — The canister ID of every canister with a registered ID in the current environment See [Environment Variables Reference](../reference/environment-variables.md) for full details. diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 25e0629a2..0085b17b0 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -45,7 +45,7 @@ The canister ID (principal) of the canister being synced. ### `ICP_CLI_CID_` -The canister ID (principal) of every canister in the project, one variable per canister. `` is the canister name uppercased with `-`, `.`, and spaces replaced by `_`. +The canister ID (principal) of every canister with a registered ID in the current environment, one variable per canister. `` is the canister name uppercased with any non-alphanumeric character replaced by `_`. For example, a project with canisters `backend` and `my-frontend` produces: