Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f05b5a5
feat(manifest): add dependencies block to project manifest
lwshang Jul 9, 2026
e42897d
feat(project): consolidate dependency projects into the canonical pro…
lwshang Jul 9, 2026
263b0db
feat(deploy): inject per-project canister-id env vars; make artifact …
lwshang Jul 9, 2026
4f747e8
fix(url): fall back to principal host for namespaced canister URLs
lwshang Jul 9, 2026
e184be8
docs(deps): example, integration test, and docs for project dependencies
lwshang Jul 9, 2026
8bf818a
test(deps): example and integration test for shared ("umbrella") depe…
lwshang Jul 9, 2026
fd450da
chore: fmt
lwshang Jul 9, 2026
7354b0e
fix(deps): upgrade crossbeam-epoch fo RUSTSEC-2026-0204
lwshang Jul 9, 2026
465d6f6
Merge branch 'main' into feat/project-dependencies
lwshang Jul 9, 2026
dd7f658
feat(project): resolve to workspace root when run inside a vendored m…
lwshang Jul 13, 2026
f5ab149
feat(deploy): scope to the current member when run inside a vendored …
lwshang Jul 13, 2026
2d2f401
feat(project): merge a member's own env config; require members to de…
lwshang Jul 13, 2026
cee9354
docs: drop references to the unversioned design doc from code comments
lwshang Jul 13, 2026
d0ccc72
docs(concepts): add Project Dependencies page
lwshang Jul 13, 2026
30eb222
docs(changelog): condense project-dependencies entry
lwshang Jul 13, 2026
3f328f0
Merge remote-tracking branch 'origin/main' into feat/project-dependen…
lwshang Jul 14, 2026
a199c7d
feat(deploy): friendly URLs for dependency canisters
lwshang Jul 14, 2026
a217133
Merge remote-tracking branch 'origin/main' into feat/project-dependen…
lwshang Jul 14, 2026
aca76d9
fix(project-dependencies): address Copilot review
lwshang Jul 14, 2026
e15d72a
fix(project-dependencies): enforce a strict canister/alias name rule
lwshang Jul 14, 2026
f58f89c
docs(project-dependencies): document the strict canister/alias name rule
lwshang Jul 14, 2026
c6b96e7
fix(project-dependencies): address flagged review comments #6/#7/#9
lwshang Jul 14, 2026
e05ac75
docs(changelog): mark project dependencies experimental; fix name-rul…
lwshang Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Convention: changes to experimental features live in a dedicated
`## Experimental` subsection under each version. Experimental features
may receive breaking changes between releases without a major version
bump. Currently experimental: project bundling
bump. Currently experimental: project bundling, project dependencies
-->

# Unreleased
Expand All @@ -16,6 +16,14 @@ bump. Currently experimental: project bundling
* `network status` reports where the key came from — a `root_key_source` field in `--json`, and a `(fetched - unverified, trust-on-first-use)` label in text output.
* `root-key` is now required for connected networks (previously optional, silently defaulting to the mainnet key). This is technically breaking, but most working projects are unaffected: a non-mainnet connected network already needed an explicit key, so in practice only a mainnet-via-custom-URL network needs to add `root-key: mainnet`. The built-in `ic` network is unchanged.

## Experimental

* feat: Projects can now depend on other `icp` projects vendored into them (e.g. as git submodules) via a top-level `dependencies:` block in `icp.yaml`.
* `icp deploy` deploys the dependency alongside your project and injects its canister IDs.
* Running `icp` from inside a vendored sub-project resolves up to the workspace root, so the whole workspace shares one network and one set of canister IDs.
* Canister names and dependency aliases must now contain only ASCII letters, digits, `_`, and `-`, with `:` reserved as the dependency namespace separator. Names using other characters are now rejected.
* See the [Project Dependencies](docs/concepts/project-dependencies.md) concept guide for details.

# v1.0.2

* feat(sync-plugin): `plugin` sync steps now reject any `dirs`/`files` entry that is, or traverses, a symlink. Together with the existing relative-path and `..` checks, this keeps a declared path from resolving to a target outside the canister directory. The restriction may be relaxed in a future release if a safe use case emerges.
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 87 additions & 20 deletions crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use icp::{
};
use icp_canister_interfaces::candid_ui::MAINNET_CANDID_UI_CID;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::time::Duration;
use tracing::info;

Expand Down Expand Up @@ -98,12 +98,24 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:

let env = ctx.get_environment(&environment_selection).await?;

let cnames = match args.names.is_empty() {
// No canisters specified
true => env.canisters.keys().cloned().collect(),

// Individual canisters specified
false => args.names.clone(),
let mut member_scoped = false;
let cnames: Vec<String> = if args.names.is_empty() {
// No canisters specified: default to the whole environment, unless the
// command is run inside a vendored member — then scope to that member's
// own canisters. (The resolved-root notice is emitted centrally during
// project load.)
let project = ctx.project.load().await?;
Comment thread
lwshang marked this conversation as resolved.
let member_dir = ctx.project.member_dir();
match icp::project::member_scoped_canisters(&project.dir, member_dir.as_deref(), &env) {
Some(scoped) => {
member_scoped = true;
scoped
}
None => env.canisters.keys().cloned().collect(),
}
} else {
// Individual canisters specified.
args.names.clone()
};

// Skip doing any work if no canisters are targeted
Expand All @@ -115,6 +127,37 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
anyhow::bail!("--args and --args-file can only be used when deploying a single canister");
}

// A member-scoped deploy targets only the sub-project's own canisters, but
// those canisters are wired to their dependencies' ids — and the dependency
// canisters are outside the scope, so they are not (re)deployed here. If any
// are missing from the workspace store, fail fast rather than silently
// deploying an unwired canister.
if member_scoped {
let scoped: HashSet<&str> = cnames.iter().map(String::as_str).collect();
let deployed: BTreeMap<String, Principal> = ctx
.ids_by_environment(&environment_selection)
.await?
.into_iter()
.collect();
let mut missing: BTreeSet<String> = BTreeSet::new();
for name in &cnames {
if let Some((_, canister)) = env.canisters.get(name) {
for target in canister.bindings.values() {
if !scoped.contains(target.as_str()) && !deployed.contains_key(target) {
missing.insert(target.clone());
}
}
}
}
if !missing.is_empty() {
anyhow::bail!(
"this sub-project depends on canister(s) not yet deployed in the workspace: {}. \
Run `icp deploy` from the workspace root first (or deploy them explicitly by name).",
missing.into_iter().collect::<Vec<_>>().join(", ")
);
}
}

let canisters_to_build = try_join_all(
cnames
.iter()
Expand Down Expand Up @@ -627,22 +670,46 @@ async fn print_canister_urls(

if let Some(http_gateway_url) = &http_gateway_url {
let has_http = has_http_request(&agent, canister_id).await;
let friendly = if has_friendly {
Some((name.as_str(), environment_selection.name()))
} else {
None
};

if has_http {
let canister_url = canister_gateway_url(http_gateway_url, canister_id, friendly);
if json {
json_canisters.push(JsonDeployedCanister {
name: name.clone(),
canister_id,
url: Some(canister_url.to_string()),
});
// A canister carries one friendly name normally, or several when
// it's a de-duplicated shared dependency canister reached via
// multiple alias chains — print one URL for each. Fall back to a
// single principal URL when friendly domains are off or no
// friendly name is known.
let env_name = environment_selection.name();
let friendly_names: Vec<String> = if has_friendly {
env.canisters
.get(name)
.map(|(_, c)| c.friendly_names.clone())
.unwrap_or_default()
} else {
Vec::new()
};
let urls = if friendly_names.is_empty() {
vec![canister_gateway_url(http_gateway_url, canister_id, None)]
} else {
println!(" {name}: {canister_url}");
friendly_names
.iter()
.map(|fname| {
canister_gateway_url(
http_gateway_url,
canister_id,
Some((fname.as_str(), env_name)),
)
})
.collect::<Vec<_>>()
};
for canister_url in &urls {
if json {
json_canisters.push(JsonDeployedCanister {
name: name.clone(),
canister_id,
url: Some(canister_url.to_string()),
});
} else {
println!(" {name}: {canister_url}");
}
}
} else {
// For canisters without http_request, show the Candid UI URL
Expand Down
4 changes: 3 additions & 1 deletion crates/icp-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,11 @@ mod heading {
)]
struct Cli {
/// Directory to use as your project root directory.
/// If not specified the directory structure is traversed up until an icp.yaml file is found
/// If not specified the directory structure is traversed up to the workspace
/// root (the top-most project that declares the one you are in as a dependency).
#[arg(
long,
env = "ICP_PROJECT_ROOT",
global = true,
help_heading = heading::GLOBAL_PARAMETERS
)]
Expand Down
24 changes: 18 additions & 6 deletions crates/icp-cli/src/operations/binding_env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,34 @@ pub(crate) async fn set_binding_env_vars_many(
.fail();
}

let binding_vars = canister_list
.iter()
.map(|(n, p)| (format!("PUBLIC_CANISTER_ID:{n}"), p.to_text()))
.collect::<Vec<(_, _)>>();

let mut futs = FuturesOrdered::new();
let progress_manager = ProgressManager::new(ProgressManagerSettings { hidden: debug });

for (cid, info) in target_canisters {
let pb = progress_manager.create_progress_bar(&info.name);
let canister_name = info.name.clone();

// Each canister receives only the ids it is wired to (its own project's
// canisters by their local names, plus any declared dependencies under
// their aliases), resolved to the ids that exist in this environment.
// A project without dependencies wires every canister to every sibling,
// reproducing the previous flat behavior.
let binding_vars: Vec<(String, String)> = info
.bindings
.iter()
.filter_map(|(env_name, referenced_key)| {
canister_list.get(referenced_key).map(|principal| {
(
format!("PUBLIC_CANISTER_ID:{env_name}"),
principal.to_text(),
)
})
})
.collect();

let settings_fn = {
let agent = agent.clone();
let pb = pb.clone();
let binding_vars = binding_vars.clone();

async move {
pb.set_message("Updating environment variables...");
Expand Down
26 changes: 26 additions & 0 deletions crates/icp-cli/src/operations/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ pub enum BundleError {
names: Vec<String>,
},

#[snafu(display(
"bundling a project with dependencies is not yet supported: canister '{canister}' is \
imported from a dependency (namespaced name). A flattened bundle would lose the alias-based \
canister-ID wiring and dependency environment config, and its ':'-containing names cannot \
be reloaded. Bundle from a project without dependencies."
))]
DependencyWorkspace { canister: String },

#[snafu(transparent)]
Build { source: BuildManyError },

Expand Down Expand Up @@ -271,6 +279,10 @@ pub(crate) async fn create_bundle(

let bundle_manifest = ProjectManifest {
canisters: canister_items,
// A bundle flattens every consolidated canister (including any pulled in
// from dependencies) into `canisters` as pre-built entries, so no external
// dependency references remain.
dependencies: vec![],
Comment thread
lwshang marked this conversation as resolved.
Comment thread
lwshang marked this conversation as resolved.
networks,
environments,
};
Expand Down Expand Up @@ -742,6 +754,20 @@ fn append_dir<W: Write>(
/// - no sync step is a script (we cannot replay an arbitrary shell command from the bundle)
/// - all sanitized canister names are unique (otherwise archive paths collide silently)
fn validate_canisters(canisters: &[(PathBuf, Canister)]) -> Result<(), BundleError> {
// A namespaced name means the canister was imported from a dependency. The
// bundle format flattens canisters into a dependency-less manifest, which
// would drop the alias-based wiring / dependency env config and produce
// ':'-containing names that fail to reload; reject it up front rather than
// emit a broken archive.
for (_, canister) in canisters {
if canister.name.contains(':') {
return DependencyWorkspaceSnafu {
canister: canister.name.clone(),
}
.fail();
}
}

for (_, canister) in canisters {
for step in &canister.sync.steps {
if matches!(step, SyncStep::Script(_)) {
Expand Down
Loading
Loading