From 4b5bcc7350cc582f1fb69a2fa12986132548958e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 17 Jun 2026 18:37:28 +0200 Subject: [PATCH 1/5] docs(AGENTS.md): test.sh over Makefile, ic-cdk 0.20, Rust env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makefile → test.sh: - Rename section and update all templates/checklist references - Use #!/usr/bin/env bash + set -e; plain $var syntax; no @/$$ - Add delta-based balance check pattern for idempotency - Update CI workflow template and README structure template Rust additions: - Always use ic-cdk 0.20; ic-ledger-types 0.16 required for 0.20 compat (0.15 + 0.20 causes ic-cdk-executor links conflict) - ic_cdk::export_candid!() required at end of every lib.rs - icp-dev-env-rust:1.0.1+ required (0.1.4 candid-extractor fails on ic0::cost_call imported by ic-cdk ≥ 0.19) - ic_cdk::api::env_var_value for runtime env vars (not env!() or std::env::var()) Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 135 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 76 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff7f36926a..f338af64c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ Follow the `hello_world` layout. New examples and migrations should use this pat ``` // ├── icp.yaml # canister definitions (icp-cli project file) -├── Makefile # must contain a `test` target +├── test.sh # executable bash test script ├── README.md ├── package.json # npm workspaces root pointing to frontend/ ├── mops.toml # Motoko only @@ -229,6 +229,25 @@ candid = "0.10" ic-cdk = "0.20" ``` +Always use `ic-cdk = "0.20"` unless a dependency forces a lower version. When using `ic-ledger-types`, use `"0.16"` (requires `ic-cdk = "^0.19"`) — version `0.15` pins `ic-cdk = "^0.18"` which conflicts with `0.20` via an `ic-cdk-executor` `links` constraint. + +**`ic_cdk::export_candid!()` is required** at the end of every Rust canister `lib.rs`. Without it, `candid-extractor` cannot find the `get_candid_pointer` export and the build fails. + +**CI image**: use `ghcr.io/dfinity/icp-dev-env-rust:1.0.1` or later. Earlier images bundle `candid-extractor 0.1.4` which fails with `Error: unknown import: ic0::cost_call` for any WASM compiled with ic-cdk ≥ 0.19. + +### Environment variables in Rust canisters + +Use `ic_cdk::api::env_var_value("VAR_NAME")` to read icp-cli-injected variables at **runtime** from WASM metadata. This is the Rust equivalent of `Runtime.envVar` in Motoko: + +```rust +fn ledger_principal() -> Principal { + Principal::from_text(ic_cdk::api::env_var_value("ICP_LEDGER_CANISTER_ID")) + .expect("invalid ICP_LEDGER_CANISTER_ID") +} +``` + +Do **not** use `env!()` (compile-time macro, fails if the var is not set during `cargo build`) or `std::env::var()` (no OS environment in WASM). + ### Management canister (Rust) Use the [`ic-cdk-management-canister`](https://crates.io/crates/ic-cdk-management-canister) crate instead of `ic_cdk::api::management_canister` (removed in ic-cdk 0.17+): @@ -249,31 +268,30 @@ async fn get_randomness() -> Vec { --- -## Makefile +## test.sh -Every example must have a `Makefile` with a `test` target that exercises the deployed canister via `icp canister call`. Write a numbered test for every public function. Include state-assertion tests for mutating operations: call the mutating function, then call a read function and assert the stored value changed. +Every example must have a `test.sh` bash script that exercises the deployed canister via `icp canister call`. Write a numbered test for every public function. Include state-assertion tests for mutating operations: call the mutating function, then call a read function and assert the stored value changed. -```makefile -.PHONY: test +Using `test.sh` instead of `Makefile` avoids Make-specific syntax pitfalls (`$$`, `\\` continuations, `@` prefix) and works natively in Git Bash on Windows. -test: - @echo "=== Test 1: ===" - @result=$$(icp canister call backend '') && \ - echo "$$result" && \ - echo "$$result" | grep -q '' && \ - echo "PASS" || (echo "FAIL" && exit 1) - - @echo "=== Test 2: ===" - @result=$$(icp canister call backend '') && \ - echo "$$result" && \ - echo "$$result" | grep -q '' && \ - echo "PASS" || (echo "FAIL" && exit 1) - - @echo "=== Test 3: ===" - @result=$$(icp canister call backend '()') && \ - echo "$$result" && \ - echo "$$result" | grep -q '' && \ - echo "PASS" || (echo "FAIL" && exit 1) +```bash +#!/usr/bin/env bash +set -e + +echo "=== Test 1: ===" +result=$(icp canister call backend '') +echo "$result" +echo "$result" | grep -q '' && echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 2: ===" +result=$(icp canister call backend '') +echo "$result" +echo "$result" | grep -q '' && echo "PASS" || (echo "FAIL" && exit 1) + +echo "=== Test 3: ===" +result=$(icp canister call backend '()') +echo "$result" +echo "$result" | grep -q '' && echo "PASS" || (echo "FAIL" && exit 1) ``` - Tests must call the `backend` canister by that name. @@ -281,42 +299,40 @@ test: - Use `grep -q` to assert on output content. - Number each test (`=== Test N: ... ===`) so CI logs are easy to scan. - For examples that create child canisters, capture the returned principal and call the child directly by ID. -- **Query functions**: always pass `--query` to `icp canister call` for `public query func` and `public composite query func` methods — this matches the original dfx `--query` flag and avoids unnecessary update calls. -- **Async/time-dependent behavior**: if the example's observable result depends on timers, heartbeats, or polling (e.g. a hook that fires after memory fills up), use a polling loop in the Makefile rather than a one-shot call: - -```makefile -test: - @echo "=== Polling for (up to 60s) ===" - @secs=0; \ - while [ $$secs -lt 60 ]; do \ - result=$$(icp canister call --query backend '()'); \ - echo "$$result"; \ - echo "$$result" | grep -q '' && echo "PASS" && exit 0; \ - sleep 3; secs=$$((secs + 3)); \ - done; \ - echo "FAIL: condition not met within 60s"; exit 1 -``` - -- **Canister settings**: if the example requires non-default canister settings (e.g. `wasm_memory_limit`, `wasm_memory_threshold`), apply them via `icp canister settings update` in a `configure` target and make `test` depend on it: +- **Query functions**: always pass `--query` to `icp canister call` for `public query func` and `public composite query func` methods. +- **Balance checks**: use delta-based assertions (record before, act, assert delta) rather than checking absolute values — this keeps tests idempotent across re-runs regardless of prior state: -```makefile -.PHONY: configure test +```bash +before=$(icp canister call backend get_balance '()' | grep -oE '[0-9_]+' | tr -d '_' | head -1) +icp token transfer 1 "$account_hex" +after=$(icp canister call backend get_balance '()' | grep -oE '[0-9_]+' | tr -d '_' | head -1) +delta=$((after - before)) +[ "$delta" -eq 100000000 ] && echo "PASS" || (echo "FAIL: expected +100000000 e8s" && exit 1) +``` -configure: - icp canister settings update backend -- -f +- **Async/time-dependent behavior**: if the observable result depends on timers, heartbeats, or polling, use a polling loop: -test: configure - ... +```bash +echo "=== Polling for (up to 60s) ===" +secs=0 +while [ "$secs" -lt 60 ]; do + result=$(icp canister call --query backend '()') + echo "$result" + echo "$result" | grep -q '' && echo "PASS" && exit 0 + sleep 3 + secs=$((secs + 3)) +done +echo "FAIL: condition not met within 60s"; exit 1 ``` -- If `icp deploy --cycles 30t` is required (see below), add a `topup` target too: +- **Canister settings**: if the example requires non-default canister settings (e.g. `wasm_memory_limit`, `wasm_memory_threshold`), apply them at the top of `test.sh` before the tests: -```makefile -.PHONY: topup -topup: - icp canister top-up --amount 30t backend +```bash +icp canister settings update backend -- -f ``` +- If `icp deploy --cycles 30t` is required (see below), add a separate `topup.sh` or document the command in the README. + --- ## Devcontainer config @@ -358,7 +374,7 @@ jobs: run: | icp network start -d icp deploy - make test + bash test.sh rust-: runs-on: ubuntu-24.04 @@ -372,7 +388,7 @@ jobs: run: | icp network start -d icp deploy - make test + bash test.sh ``` - Linux only, no macOS runners. @@ -412,10 +428,10 @@ Each example's README should follow this structure: ### Deploy and test - + If the example has a frontend with a Vite dev server: `npm run dev` (hot reload during frontend development) -> If tests fail with an out-of-cycles error, run `make topup` to add 30 trillion cycles to the backend canister and retry. Only relevant for examples that create child canisters. +> If tests fail with an out-of-cycles error, run `bash topup.sh` (or the documented icp command) to add 30 trillion cycles to the backend canister and retry. Only relevant for examples that create child canisters. ## Updating the Candid interface @@ -469,15 +485,16 @@ When migrating an existing example: - [ ] Run `mops check --fix` in the example directory and commit any auto-fixes (Motoko) - [ ] If the example uses the management canister: add `ic = "4.0.0"` dependency and replace `ic:aaaaa-aa` / `actor("aaaaa-aa")` with `import { ic } "mo:ic"` (Motoko) - [ ] If the Rust example uses the management canister: add `ic-cdk-management-canister = "0.1.1"` dependency and replace `ic_cdk::api::management_canister` with the appropriate function from that crate -- [ ] If the example creates child canisters: use `icp deploy --cycles 30t` in the CI workflow and README, and add a `make topup` target -- [ ] If the example requires non-default canister settings (memory limits, freezing threshold, etc.): add a `configure` target in the Makefile and make `test` depend on it +- [ ] If the example creates child canisters: use `icp deploy --cycles 30t` in the CI workflow and README +- [ ] If the example requires non-default canister settings (memory limits, freezing threshold, etc.): apply them via `icp canister settings update` at the top of `test.sh` - [ ] Delete `dfx.json`, `BUILD.md`, `.dfx/`, `.env` (dfx-generated) - [ ] Delete `.devcontainer/` inside the example folder if one exists (only the repo-root devcontainer is kept) -### Makefile -- [ ] Add `Makefile` with `test` target +### test.sh +- [ ] Add `test.sh` (executable, `#!/usr/bin/env bash`, `set -e`) with numbered tests - [ ] Use `--query` for all `public query func` and `public composite query func` calls - [ ] Use a polling loop for any behavior that depends on timers, heartbeats, or async system hooks +- [ ] Use delta-based balance assertions (before/after) rather than absolute values for idempotency - [ ] For backend-only examples, no `## Updating the Candid interface` section is needed ### CI workflow From 20a403f9e88500c746a9a68180a0ceaeb199a63e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 17 Jun 2026 18:38:46 +0200 Subject: [PATCH 2/5] docs(AGENTS.md): use icp canister top-up command directly in README (no script) --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f338af64c1..903b92df0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -331,7 +331,7 @@ echo "FAIL: condition not met within 60s"; exit 1 icp canister settings update backend -- -f ``` -- If `icp deploy --cycles 30t` is required (see below), add a separate `topup.sh` or document the command in the README. +- If `icp deploy --cycles 30t` is required (see below), document `icp canister top-up --amount 30t backend` in the README so users can replenish cycles when needed. --- @@ -431,7 +431,7 @@ Each example's README should follow this structure: If the example has a frontend with a Vite dev server: `npm run dev` (hot reload during frontend development) -> If tests fail with an out-of-cycles error, run `bash topup.sh` (or the documented icp command) to add 30 trillion cycles to the backend canister and retry. Only relevant for examples that create child canisters. +> If tests fail with an out-of-cycles error, run `icp canister top-up --amount 30t backend` to add 30 trillion cycles and retry. Only relevant for examples that create child canisters. ## Updating the Candid interface From a7babac0ab377d0f3ba4f0f3a876889f508400b6 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 17 Jun 2026 18:39:16 +0200 Subject: [PATCH 3/5] docs(AGENTS.md): clarify 30t is an example cycle amount, not a fixed requirement --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 903b92df0d..aacbba4d18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -331,7 +331,7 @@ echo "FAIL: condition not met within 60s"; exit 1 icp canister settings update backend -- -f ``` -- If `icp deploy --cycles 30t` is required (see below), document `icp canister top-up --amount 30t backend` in the README so users can replenish cycles when needed. +- If `icp deploy --cycles 30t` is required (see below), document `icp canister top-up --amount 30t backend` in the README so users can replenish cycles when needed. (`30t` is an example amount — adjust to the example's actual consumption.) --- @@ -431,7 +431,7 @@ Each example's README should follow this structure: If the example has a frontend with a Vite dev server: `npm run dev` (hot reload during frontend development) -> If tests fail with an out-of-cycles error, run `icp canister top-up --amount 30t backend` to add 30 trillion cycles and retry. Only relevant for examples that create child canisters. +> If tests fail with an out-of-cycles error, run `icp canister top-up --amount 30t backend` to replenish cycles and retry (`30t` is an example amount). Only relevant for examples that create child canisters. ## Updating the Candid interface From 41dd6a368b67fc437b223a7a8142a0cb111d2e0a Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 17 Jun 2026 18:40:19 +0200 Subject: [PATCH 4/5] docs(AGENTS.md): pin all dev-env images to v1.0.1 --- AGENTS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aacbba4d18..bf68c16904 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,7 @@ canisters: ``` - With `candid:` specified: the recipe reads the committed `.did` file and embeds it as WASM metadata (no `candid-extractor` needed). -- Without `candid:`: `candid-extractor` (available in `icp-dev-env-rust:0.3.2+`) extracts the interface directly from the compiled WASM. For backend-only examples, omit `candid:` and do not commit `backend.did`. +- Without `candid:`: `candid-extractor` extracts the interface directly from the compiled WASM. For backend-only examples, omit `candid:` and do not commit `backend.did`. **Canister names are always `backend` and `frontend`.** Never use names like `_backend`, `internet_identity_app_backend`, etc. @@ -364,7 +364,7 @@ concurrency: jobs: motoko-: runs-on: ubuntu-24.04 - container: ghcr.io/dfinity/icp-dev-env-motoko: + container: ghcr.io/dfinity/icp-dev-env-motoko:1.0.1 env: ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -378,7 +378,7 @@ jobs: rust-: runs-on: ubuntu-24.04 - container: ghcr.io/dfinity/icp-dev-env-rust: + container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1 env: ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -454,7 +454,7 @@ Rust: `icp build backend && candid-extractor target/wasm32-unknown-unknown/relea ## Pending items (do not resolve prematurely) ### Container images -Images are published at `ghcr.io/dfinity/icp-dev-env-{motoko,rust,all}`. All devcontainer configs and CI workflows reference the pinned tag (e.g. `0.3.2`). When a new release is cut, update the tag in: +Images are published at `ghcr.io/dfinity/icp-dev-env-{motoko,rust,all}`. Current pinned version: **`1.0.1`**. All devcontainer configs and CI workflows reference the pinned tag. When a new release is cut, update the tag in: - `.devcontainer/devcontainer.json` - `.github/workflows/*.yml` From 8a1b8142094b347d5b4d6093ef1a56e0c8e4aa82 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 17 Jun 2026 18:44:07 +0200 Subject: [PATCH 5/5] docs(AGENTS.md): add per-environment canister config guidance and checklist item --- AGENTS.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index bf68c16904..3d8f94e528 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,43 @@ canisters: **Canister names are always `backend` and `frontend`.** Never use names like `_backend`, `internet_identity_app_backend`, etc. +### Per-environment canister configuration + +When an example interacts with a well-known external canister whose principal differs by environment (e.g. the ICP ledger mainnet vs. TESTICP on staging), use the `environments` block to inject the right value per deployment rather than hardcoding it: + +```yaml +canisters: + - name: backend + recipe: + type: "@dfinity/rust@v3.3.0" + +environments: + - name: local + network: local + settings: + backend: + environment_variables: + ICP_LEDGER_CANISTER_ID: "ryjl3-tyaaa-aaaaa-aaaba-cai" + + - name: staging + network: ic + settings: + backend: + environment_variables: + ICP_LEDGER_CANISTER_ID: "xafvr-biaaa-aaaai-aql5q-cai" # TESTICP + + - name: production + network: ic + settings: + backend: + environment_variables: + ICP_LEDGER_CANISTER_ID: "ryjl3-tyaaa-aaaaa-aaaba-cai" +``` + +icp-cli injects these as WASM metadata before `cargo build`. Read them at **runtime** via `ic_cdk::api::env_var_value("ICP_LEDGER_CANISTER_ID")` (Rust) or `Runtime.envVar("ICP_LEDGER_CANISTER_ID")` (Motoko) — not via `env!()` or `std::env::var()`. + +**When to apply this pattern**: judge whether staging/production environments make sense for the specific example. Apply it when the example hardcodes an external canister principal that has a test counterpart (TESTICP, testnet URLs, staging governance canisters, etc.). Skip it for self-contained examples that don't call external canisters. + ### Motoko naming conventions - **Top-level actor:** name it after its logical role, not generically — e.g. `actor TodoList`, `actor CanisterFactory`, not `actor Backend`. @@ -485,6 +522,7 @@ When migrating an existing example: - [ ] Run `mops check --fix` in the example directory and commit any auto-fixes (Motoko) - [ ] If the example uses the management canister: add `ic = "4.0.0"` dependency and replace `ic:aaaaa-aa` / `actor("aaaaa-aa")` with `import { ic } "mo:ic"` (Motoko) - [ ] If the Rust example uses the management canister: add `ic-cdk-management-canister = "0.1.1"` dependency and replace `ic_cdk::api::management_canister` with the appropriate function from that crate +- [ ] **Judge whether per-environment configuration makes sense**: if the example calls an external canister whose principal differs by environment (e.g. ICP ledger vs. TESTICP), add an `environments` block to `icp.yaml`. Skip for self-contained examples. - [ ] If the example creates child canisters: use `icp deploy --cycles 30t` in the CI workflow and README - [ ] If the example requires non-default canister settings (memory limits, freezing threshold, etc.): apply them via `icp canister settings update` at the top of `test.sh` - [ ] Delete `dfx.json`, `BUILD.md`, `.dfx/`, `.env` (dfx-generated)